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,233 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DomainConfig,
|
|
3
|
+
EntityConfig,
|
|
4
|
+
SubscribableConfig,
|
|
5
|
+
IncludeConfig,
|
|
6
|
+
GraphRuleActionConfig,
|
|
7
|
+
GraphRuleConfig,
|
|
8
|
+
ManyToManyConfig,
|
|
9
|
+
DomainIR,
|
|
10
|
+
EntityIR,
|
|
11
|
+
SubscribableIR,
|
|
12
|
+
IncludeIR,
|
|
13
|
+
CustomFunctionIR,
|
|
14
|
+
ManyToManyIR,
|
|
15
|
+
GraphRuleIR
|
|
16
|
+
} from "../../shared/ir.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parses a graph rule config into IR format.
|
|
20
|
+
* Handles both formats:
|
|
21
|
+
* 1. Direct: { actions: [...] }
|
|
22
|
+
* 2. Named: { rule_name: { description, condition, actions } }
|
|
23
|
+
*/
|
|
24
|
+
function parseGraphRules(rules: Record<string, GraphRuleConfig> | { actions: GraphRuleActionConfig[] } | undefined): GraphRuleIR[] {
|
|
25
|
+
if (!rules) return [];
|
|
26
|
+
|
|
27
|
+
const allActions: GraphRuleIR[] = [];
|
|
28
|
+
|
|
29
|
+
// Helper to extract target from action config
|
|
30
|
+
const getTarget = (action: GraphRuleActionConfig): string => {
|
|
31
|
+
// For validate/execute, use 'function'; for delete/update use 'target'; for create use 'entity'; for reactor use 'name'
|
|
32
|
+
return action.function || action.target || action.entity || action.name || '';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Helper to build a GraphRuleIR from an action config
|
|
36
|
+
const buildRuleIR = (
|
|
37
|
+
action: GraphRuleActionConfig,
|
|
38
|
+
ruleName?: string,
|
|
39
|
+
ruleDescription?: string,
|
|
40
|
+
ruleCondition?: string
|
|
41
|
+
): GraphRuleIR => {
|
|
42
|
+
return {
|
|
43
|
+
trigger: 'create', // Will be set by caller
|
|
44
|
+
action: action.type as 'create' | 'update' | 'delete' | 'reactor' | 'validate' | 'execute',
|
|
45
|
+
target: getTarget(action),
|
|
46
|
+
ruleName,
|
|
47
|
+
description: ruleDescription,
|
|
48
|
+
condition: ruleCondition,
|
|
49
|
+
params: action.params || action.data || {},
|
|
50
|
+
match: action.match,
|
|
51
|
+
error_message: action.error_message
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Case 1: Direct rule (has 'actions' at top level)
|
|
56
|
+
if ('actions' in rules && Array.isArray(rules.actions)) {
|
|
57
|
+
for (const action of rules.actions) {
|
|
58
|
+
allActions.push(buildRuleIR(action));
|
|
59
|
+
}
|
|
60
|
+
return allActions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Case 2: Named rules (iterate values)
|
|
64
|
+
for (const [ruleName, ruleConfig] of Object.entries(rules)) {
|
|
65
|
+
if (ruleConfig && typeof ruleConfig === 'object' && 'actions' in ruleConfig && Array.isArray(ruleConfig.actions)) {
|
|
66
|
+
for (const action of ruleConfig.actions) {
|
|
67
|
+
allActions.push(buildRuleIR(
|
|
68
|
+
action,
|
|
69
|
+
ruleName,
|
|
70
|
+
ruleConfig.description,
|
|
71
|
+
ruleConfig.condition
|
|
72
|
+
));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return allActions;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parses include config (handles both string shorthand and full object)
|
|
82
|
+
*/
|
|
83
|
+
function parseIncludes(rawIncludes: Record<string, string | IncludeConfig> | undefined): Record<string, IncludeIR> {
|
|
84
|
+
const parsed: Record<string, IncludeIR> = {};
|
|
85
|
+
if (!rawIncludes) return parsed;
|
|
86
|
+
|
|
87
|
+
for (const [key, val] of Object.entries(rawIncludes)) {
|
|
88
|
+
// Handle shorthand string: "org": "organisations"
|
|
89
|
+
if (typeof val === 'string') {
|
|
90
|
+
parsed[key] = {
|
|
91
|
+
relation: key,
|
|
92
|
+
entity: val,
|
|
93
|
+
includes: {}
|
|
94
|
+
};
|
|
95
|
+
} else {
|
|
96
|
+
// Handle full object: "sites": { entity: "sites", ... }
|
|
97
|
+
parsed[key] = {
|
|
98
|
+
relation: key,
|
|
99
|
+
entity: val.entity || key,
|
|
100
|
+
filter: val.filter,
|
|
101
|
+
includes: parseIncludes(val.includes as Record<string, string | IncludeConfig> | undefined)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return parsed;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Generates IR from a typed domain configuration
|
|
110
|
+
*/
|
|
111
|
+
export function generateIR(domain: DomainConfig): DomainIR {
|
|
112
|
+
const entities: Record<string, EntityIR> = {};
|
|
113
|
+
|
|
114
|
+
// --- ENTITIES ---
|
|
115
|
+
for (const [name, config] of Object.entries(domain.entities)) {
|
|
116
|
+
const columns: Array<{ name: string; type: string; isArray: boolean }> = [];
|
|
117
|
+
const pk: string[] = [];
|
|
118
|
+
|
|
119
|
+
// Parse Schema
|
|
120
|
+
for (const [colName, colType] of Object.entries(config.schema)) {
|
|
121
|
+
columns.push({
|
|
122
|
+
name: colName,
|
|
123
|
+
type: colType,
|
|
124
|
+
isArray: colType.includes('[]')
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (colType.toUpperCase().includes('PRIMARY KEY')) {
|
|
128
|
+
pk.push(colName);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Check for explicit primaryKey array in config (for composite PKs)
|
|
133
|
+
if (config.primaryKey && Array.isArray(config.primaryKey) && config.primaryKey.length > 0) {
|
|
134
|
+
pk.length = 0; // Clear any auto-detected PKs
|
|
135
|
+
pk.push(...config.primaryKey);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Default PK if none found
|
|
139
|
+
if (pk.length === 0 && columns.some(c => c.name === 'id')) {
|
|
140
|
+
pk.push('id');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Parse permissions
|
|
144
|
+
const rawPerms = config.permissions || {};
|
|
145
|
+
const permissions = {
|
|
146
|
+
view: rawPerms.view || [],
|
|
147
|
+
create: rawPerms.create || [],
|
|
148
|
+
update: rawPerms.update || [],
|
|
149
|
+
delete: rawPerms.delete || []
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Parse Graph Rules
|
|
153
|
+
const rawRules = config.graphRules || {};
|
|
154
|
+
|
|
155
|
+
const onCreateRules = parseGraphRules(rawRules.on_create);
|
|
156
|
+
onCreateRules.forEach(r => r.trigger = 'create');
|
|
157
|
+
|
|
158
|
+
const onUpdateRules = parseGraphRules(rawRules.on_update);
|
|
159
|
+
onUpdateRules.forEach(r => r.trigger = 'update');
|
|
160
|
+
|
|
161
|
+
const onDeleteRules = parseGraphRules(rawRules.on_delete);
|
|
162
|
+
onDeleteRules.forEach(r => r.trigger = 'delete');
|
|
163
|
+
|
|
164
|
+
// Parse M2M relationships
|
|
165
|
+
const rawM2M = config.manyToMany || {};
|
|
166
|
+
const manyToMany: Record<string, ManyToManyIR> = {};
|
|
167
|
+
for (const [relationKey, m2mConfig] of Object.entries(rawM2M)) {
|
|
168
|
+
manyToMany[relationKey] = {
|
|
169
|
+
junctionTable: m2mConfig.junctionTable,
|
|
170
|
+
localKey: m2mConfig.localKey,
|
|
171
|
+
foreignKey: m2mConfig.foreignKey,
|
|
172
|
+
targetEntity: m2mConfig.targetEntity,
|
|
173
|
+
idField: m2mConfig.idField,
|
|
174
|
+
expand: m2mConfig.expand || false
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
entities[name] = {
|
|
179
|
+
name,
|
|
180
|
+
table: name,
|
|
181
|
+
primaryKey: pk,
|
|
182
|
+
columns,
|
|
183
|
+
labelField: config.label || 'id',
|
|
184
|
+
softDelete: config.softDelete || false,
|
|
185
|
+
managed: config.managed !== false, // Default to true, only false if explicitly set
|
|
186
|
+
hidden: config.hidden || [],
|
|
187
|
+
fieldDefaults: config.fieldDefaults || {},
|
|
188
|
+
permissions,
|
|
189
|
+
relationships: {},
|
|
190
|
+
manyToMany,
|
|
191
|
+
graphRules: {
|
|
192
|
+
onCreate: onCreateRules,
|
|
193
|
+
onUpdate: onUpdateRules,
|
|
194
|
+
onDelete: onDeleteRules
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- SUBSCRIBABLES ---
|
|
200
|
+
const subscribables: Record<string, SubscribableIR> = {};
|
|
201
|
+
|
|
202
|
+
if (domain.subscribables) {
|
|
203
|
+
for (const [name, config] of Object.entries(domain.subscribables)) {
|
|
204
|
+
subscribables[name] = {
|
|
205
|
+
name,
|
|
206
|
+
params: config.params || {},
|
|
207
|
+
root: config.root || { entity: '', key: '' },
|
|
208
|
+
includes: parseIncludes(config.includes as Record<string, string | IncludeConfig> | undefined),
|
|
209
|
+
scopeTables: config.scopeTables || [],
|
|
210
|
+
canSubscribe: config.canSubscribe || []
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- CUSTOM FUNCTIONS ---
|
|
216
|
+
const customFunctions: CustomFunctionIR[] = [];
|
|
217
|
+
|
|
218
|
+
if (domain.customFunctions) {
|
|
219
|
+
for (const fn of domain.customFunctions) {
|
|
220
|
+
customFunctions.push({
|
|
221
|
+
name: fn.name,
|
|
222
|
+
sql: fn.sql,
|
|
223
|
+
args: fn.args || ['p_user_id', 'p_params']
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
entities,
|
|
230
|
+
subscribables,
|
|
231
|
+
customFunctions
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { resolve } from "path";
|
|
2
|
+
import type { DomainConfig, EntityConfig, SubscribableConfig, CustomFunctionConfig } from "../../shared/ir.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates that an entity config has the required fields
|
|
6
|
+
*/
|
|
7
|
+
function validateEntityConfig(name: string, config: unknown): EntityConfig {
|
|
8
|
+
if (!config || typeof config !== 'object') {
|
|
9
|
+
throw new Error(`Entity '${name}' must be an object`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const c = config as Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
if (!c.schema || typeof c.schema !== 'object') {
|
|
15
|
+
throw new Error(`Entity '${name}' must have a 'schema' object`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Return typed config (schema is the only required field)
|
|
19
|
+
return config as EntityConfig;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validates that a subscribable config has the required fields
|
|
24
|
+
*/
|
|
25
|
+
function validateSubscribableConfig(name: string, config: unknown): SubscribableConfig {
|
|
26
|
+
if (!config || typeof config !== 'object') {
|
|
27
|
+
throw new Error(`Subscribable '${name}' must be an object`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const c = config as Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
if (!c.root || typeof c.root !== 'object') {
|
|
33
|
+
throw new Error(`Subscribable '${name}' must have a 'root' object`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const root = c.root as Record<string, unknown>;
|
|
37
|
+
if (!root.entity || typeof root.entity !== 'string') {
|
|
38
|
+
throw new Error(`Subscribable '${name}' root must have an 'entity' string`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return config as SubscribableConfig;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validates a custom function config
|
|
46
|
+
*/
|
|
47
|
+
function validateCustomFunctionConfig(fn: unknown, index: number): CustomFunctionConfig {
|
|
48
|
+
if (!fn || typeof fn !== 'object') {
|
|
49
|
+
throw new Error(`Custom function at index ${index} must be an object`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const f = fn as Record<string, unknown>;
|
|
53
|
+
|
|
54
|
+
if (!f.name || typeof f.name !== 'string') {
|
|
55
|
+
throw new Error(`Custom function at index ${index} must have a 'name' string`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!f.sql || typeof f.sql !== 'string') {
|
|
59
|
+
throw new Error(`Custom function '${f.name}' must have a 'sql' string`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return fn as CustomFunctionConfig;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Loads and validates a domain configuration file
|
|
67
|
+
*/
|
|
68
|
+
export async function loadDomain(filePath: string): Promise<DomainConfig> {
|
|
69
|
+
// Simple resolve against CWD
|
|
70
|
+
const absolutePath = resolve(process.cwd(), filePath);
|
|
71
|
+
console.log(`[Compiler] Resolving path: ${filePath}`);
|
|
72
|
+
console.log(`[Compiler] Absolute path: ${absolutePath}`);
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
// Bun can import .ts files directly
|
|
76
|
+
const module = await import(absolutePath);
|
|
77
|
+
|
|
78
|
+
if (!module.entities) {
|
|
79
|
+
throw new Error(`Module ${filePath} must export 'entities' object.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (typeof module.entities !== 'object') {
|
|
83
|
+
throw new Error(`Module ${filePath} 'entities' must be an object.`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Validate entities
|
|
87
|
+
const entities: Record<string, EntityConfig> = {};
|
|
88
|
+
for (const [name, config] of Object.entries(module.entities)) {
|
|
89
|
+
entities[name] = validateEntityConfig(name, config);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(`[Compiler] Loaded ${Object.keys(entities).length} entities.`);
|
|
93
|
+
|
|
94
|
+
// Validate subscribables if present
|
|
95
|
+
const subscribables: Record<string, SubscribableConfig> = {};
|
|
96
|
+
if (module.subscribables) {
|
|
97
|
+
if (typeof module.subscribables !== 'object') {
|
|
98
|
+
throw new Error(`Module ${filePath} 'subscribables' must be an object.`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const [name, config] of Object.entries(module.subscribables)) {
|
|
102
|
+
subscribables[name] = validateSubscribableConfig(name, config);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log(`[Compiler] Loaded ${Object.keys(subscribables).length} subscribables.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Validate custom functions if present
|
|
109
|
+
const customFunctions: CustomFunctionConfig[] = [];
|
|
110
|
+
if (module.customFunctions) {
|
|
111
|
+
if (!Array.isArray(module.customFunctions)) {
|
|
112
|
+
throw new Error(`Module ${filePath} 'customFunctions' must be an array.`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < module.customFunctions.length; i++) {
|
|
116
|
+
customFunctions.push(validateCustomFunctionConfig(module.customFunctions[i], i));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(`[Compiler] Loaded ${customFunctions.length} custom functions.`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
entities,
|
|
124
|
+
subscribables,
|
|
125
|
+
customFunctions
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error(`[Compiler] Failed to load domain module:`, error);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiles a permission rule into SQL.
|
|
3
|
+
*
|
|
4
|
+
* Supported formats:
|
|
5
|
+
* - Simple field check: @author_id (implies author_id == user_id)
|
|
6
|
+
* - Explicit comparison: @field == @user_id
|
|
7
|
+
* - Single-hop traversal: @org_id->acts_for[org_id=$]{active}.user_id
|
|
8
|
+
* - Multi-hop traversal: @venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id
|
|
9
|
+
* - Deep traversal: @site_id->sites.venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id
|
|
10
|
+
*
|
|
11
|
+
* @param entityName - The entity name
|
|
12
|
+
* @param rule - The permission rule string
|
|
13
|
+
* @param context - Optional context (unused)
|
|
14
|
+
* @param dataVar - Either a JSONB variable name (e.g., 'p_data') or a table name (e.g., 'packages')
|
|
15
|
+
* When it's a table name, we use table.column syntax instead of jsonb->>'column'
|
|
16
|
+
*/
|
|
17
|
+
export function compilePermission(entityName: string, rule: string, context: any, dataVar: string = 'p_data'): string {
|
|
18
|
+
// Determine if dataVar is a JSONB variable or a table name
|
|
19
|
+
// JSONB variables typically start with p_ or v_ (e.g., p_data, v_old_data)
|
|
20
|
+
const isJsonb = dataVar.startsWith('p_') || dataVar.startsWith('v_');
|
|
21
|
+
|
|
22
|
+
// Helper to access a field value
|
|
23
|
+
const fieldAccess = (field: string) => {
|
|
24
|
+
if (isJsonb) {
|
|
25
|
+
return `(${dataVar}->>'${field}')::int`;
|
|
26
|
+
} else {
|
|
27
|
+
return `${dataVar}.${field}`;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Case 1: Simple Field Check (@author_id)
|
|
32
|
+
// Implies: author_id == user_id
|
|
33
|
+
if (rule.match(/^@[a-zA-Z0-9_]+$/)) {
|
|
34
|
+
const field = rule.substring(1);
|
|
35
|
+
return `${fieldAccess(field)} = p_user_id`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Case 1b: Explicit Comparison (@field == @user_id)
|
|
39
|
+
const comparisonMatch = rule.match(/^@([a-zA-Z0-9_]+)\s*==\s*@user_id$/);
|
|
40
|
+
if (comparisonMatch) {
|
|
41
|
+
const field = comparisonMatch[1];
|
|
42
|
+
return `${fieldAccess(field)} = p_user_id`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Case 2: Graph Traversal (supports unlimited depth)
|
|
46
|
+
// Format: @local_field->table.field->table[filter=$]{condition}.user_id
|
|
47
|
+
if (rule.includes('->')) {
|
|
48
|
+
return compileTraversalPath(rule, dataVar, isJsonb);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Case 3: Table-first lookup (e.g., contractor_rights[package_id=@package_id]{active}.field->...)
|
|
52
|
+
// This pattern starts with a table reference and filter, not a field reference
|
|
53
|
+
const tableFirstMatch = rule.match(/^([a-zA-Z0-9_]+)\[([^\]]+)\]/);
|
|
54
|
+
if (tableFirstMatch) {
|
|
55
|
+
// This is a complex pattern that needs special handling
|
|
56
|
+
// For now, return FALSE and log a warning
|
|
57
|
+
console.warn(`[Compiler] Warning: Table-first permission path '${rule}' not fully supported yet.`);
|
|
58
|
+
return 'FALSE';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return 'FALSE'; // Default deny
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compiles a multi-hop traversal path into SQL with nested subqueries.
|
|
66
|
+
*
|
|
67
|
+
* Example: @venue_id->venues.org_id->acts_for[org_id=$]{active}.user_id
|
|
68
|
+
*
|
|
69
|
+
* Algorithm:
|
|
70
|
+
* 1. Split on '->' to get hops
|
|
71
|
+
* 2. First hop is the source field from the record
|
|
72
|
+
* 3. Intermediate hops traverse through tables via FK lookups
|
|
73
|
+
* 4. Final hop is an EXISTS check with conditions
|
|
74
|
+
*/
|
|
75
|
+
function compileTraversalPath(rule: string, dataVar: string, isJsonb: boolean): string {
|
|
76
|
+
const hops = rule.split('->');
|
|
77
|
+
|
|
78
|
+
if (hops.length < 2) {
|
|
79
|
+
return 'FALSE';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const firstHop = hops[0];
|
|
83
|
+
|
|
84
|
+
// Check if this is a table-first pattern (doesn't start with @)
|
|
85
|
+
// e.g., contractor_rights[package_id=@package_id]{active}.contractor_org_id
|
|
86
|
+
if (!firstHop.startsWith('@')) {
|
|
87
|
+
// This is a complex pattern: table[filter].field -> ...
|
|
88
|
+
// Parse the first hop to get the table, filter, and field
|
|
89
|
+
const tableFirstMatch = firstHop.match(/^([a-zA-Z0-9_]+)\[([^\]]+)\](\{[^}]+\})?\.([a-zA-Z0-9_]+)$/);
|
|
90
|
+
if (tableFirstMatch) {
|
|
91
|
+
const [_, lookupTable, filterExpr, condExpr, outputField] = tableFirstMatch;
|
|
92
|
+
|
|
93
|
+
// Parse the filter: package_id=@package_id
|
|
94
|
+
const filterMatch = filterExpr.match(/([a-zA-Z0-9_]+)=@([a-zA-Z0-9_]+)/);
|
|
95
|
+
if (!filterMatch) {
|
|
96
|
+
console.warn(`[Compiler] Warning: Cannot parse filter '${filterExpr}' in permission path.`);
|
|
97
|
+
return 'FALSE';
|
|
98
|
+
}
|
|
99
|
+
const [__, filterField, sourceRefField] = filterMatch;
|
|
100
|
+
|
|
101
|
+
// Get the source field value
|
|
102
|
+
const sourceValue = isJsonb
|
|
103
|
+
? `(${dataVar}->>'${sourceRefField}')::int`
|
|
104
|
+
: `${dataVar}.${sourceRefField}`;
|
|
105
|
+
|
|
106
|
+
// Parse condition like {active}
|
|
107
|
+
let conditionClause = '';
|
|
108
|
+
if (condExpr) {
|
|
109
|
+
const cond = condExpr.replace(/[{}]/g, '');
|
|
110
|
+
if (cond.match(/^[a-zA-Z0-9_]+$/)) {
|
|
111
|
+
conditionClause = ` AND ${lookupTable}.${cond} = true`;
|
|
112
|
+
} else {
|
|
113
|
+
conditionClause = ` AND ${lookupTable}.${cond}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Build subquery to get the output field from the lookup table
|
|
118
|
+
let valueExpr = `(SELECT ${outputField} FROM ${lookupTable} WHERE ${lookupTable}.${filterField} = ${sourceValue}${conditionClause})`;
|
|
119
|
+
|
|
120
|
+
// Now process remaining hops (all but the last, which is the EXISTS check)
|
|
121
|
+
for (let i = 1; i < hops.length - 1; i++) {
|
|
122
|
+
const hop = hops[i];
|
|
123
|
+
const dotMatch = hop.match(/^([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)$/);
|
|
124
|
+
if (dotMatch) {
|
|
125
|
+
const table = dotMatch[1];
|
|
126
|
+
const field = dotMatch[2];
|
|
127
|
+
valueExpr = `(SELECT ${field} FROM ${table} WHERE id = ${valueExpr})`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Final hop: EXISTS check
|
|
132
|
+
return buildFinalExistsCheck(hops[hops.length - 1], valueExpr);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Unrecognized pattern
|
|
136
|
+
console.warn(`[Compiler] Warning: Unrecognized table-first pattern '${firstHop}'. Returning FALSE.`);
|
|
137
|
+
return 'FALSE';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// First hop: source field from record (e.g., @venue_id -> venue_id)
|
|
141
|
+
const sourceField = firstHop.replace(/^@/, '');
|
|
142
|
+
|
|
143
|
+
// Start building the value expression
|
|
144
|
+
let valueExpr = isJsonb
|
|
145
|
+
? `(${dataVar}->>'${sourceField}')::int`
|
|
146
|
+
: `${dataVar}.${sourceField}`;
|
|
147
|
+
|
|
148
|
+
// Process intermediate hops (all but the last)
|
|
149
|
+
for (let i = 1; i < hops.length - 1; i++) {
|
|
150
|
+
const hop = hops[i];
|
|
151
|
+
|
|
152
|
+
// Parse table.field format (e.g., venues.org_id)
|
|
153
|
+
const dotMatch = hop.match(/^([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)$/);
|
|
154
|
+
if (dotMatch) {
|
|
155
|
+
const table = dotMatch[1];
|
|
156
|
+
const field = dotMatch[2];
|
|
157
|
+
// Wrap in subquery: (SELECT field FROM table WHERE id = previousValue)
|
|
158
|
+
valueExpr = `(SELECT ${field} FROM ${table} WHERE id = ${valueExpr})`;
|
|
159
|
+
} else {
|
|
160
|
+
// Just a table name - assume we're looking up by id and continuing with id
|
|
161
|
+
const table = hop.replace(/\[.*\]/, '').replace(/\{.*\}/, '').replace(/\..*$/, '');
|
|
162
|
+
valueExpr = `(SELECT id FROM ${table} WHERE id = ${valueExpr})`;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Final hop: EXISTS check with the target table
|
|
167
|
+
return buildFinalExistsCheck(hops[hops.length - 1], valueExpr);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Builds the final EXISTS check for a permission path.
|
|
172
|
+
*
|
|
173
|
+
* @param finalHop - The final hop string (e.g., "acts_for[org_id=$]{active}.user_id")
|
|
174
|
+
* @param valueExpr - The SQL expression for the value to join on
|
|
175
|
+
*/
|
|
176
|
+
function buildFinalExistsCheck(finalHop: string, valueExpr: string): string {
|
|
177
|
+
// Parse final hop components
|
|
178
|
+
// Format: table[filter=$]{condition}.user_id
|
|
179
|
+
const tableMatch = finalHop.match(/^([a-zA-Z0-9_]+)/);
|
|
180
|
+
if (!tableMatch) return 'FALSE';
|
|
181
|
+
const targetTable = tableMatch[1];
|
|
182
|
+
|
|
183
|
+
// Extract filter: [org_id=$]
|
|
184
|
+
const filterMatch = finalHop.match(/\[([a-zA-Z0-9_]+)=\$\]/);
|
|
185
|
+
const joinField = filterMatch ? filterMatch[1] : null;
|
|
186
|
+
|
|
187
|
+
// Extract condition: {active} or {role='admin'}
|
|
188
|
+
const condMatch = finalHop.match(/\{([^}]+)\}/);
|
|
189
|
+
const condition = condMatch ? condMatch[1] : null;
|
|
190
|
+
|
|
191
|
+
// Extract target field: .user_id
|
|
192
|
+
const targetFieldMatch = finalHop.match(/\.([a-zA-Z0-9_]+)$/);
|
|
193
|
+
const targetField = targetFieldMatch ? targetFieldMatch[1] : null;
|
|
194
|
+
|
|
195
|
+
// Build EXISTS query
|
|
196
|
+
const conditions: string[] = [];
|
|
197
|
+
|
|
198
|
+
// Join condition
|
|
199
|
+
if (joinField) {
|
|
200
|
+
conditions.push(`${targetTable}.${joinField} = ${valueExpr}`);
|
|
201
|
+
} else {
|
|
202
|
+
// Implicit join by id
|
|
203
|
+
conditions.push(`${targetTable}.id = ${valueExpr}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Temporal/active condition
|
|
207
|
+
if (condition) {
|
|
208
|
+
// Handle simple boolean condition like {active}
|
|
209
|
+
if (condition.match(/^[a-zA-Z0-9_]+$/)) {
|
|
210
|
+
conditions.push(`${targetTable}.${condition} = true`);
|
|
211
|
+
} else {
|
|
212
|
+
// Handle complex condition like {role='admin'}
|
|
213
|
+
conditions.push(`${targetTable}.${condition}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// User check (final target field)
|
|
218
|
+
if (targetField === 'user_id') {
|
|
219
|
+
conditions.push(`${targetTable}.user_id = p_user_id`);
|
|
220
|
+
} else if (targetField) {
|
|
221
|
+
conditions.push(`${targetTable}.${targetField} = p_user_id`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const whereClause = conditions.join(' AND ');
|
|
225
|
+
|
|
226
|
+
return `EXISTS (SELECT 1 FROM ${targetTable} WHERE ${whereClause})`;
|
|
227
|
+
}
|