dzql 0.5.33 → 0.6.0

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.
Files changed (150) hide show
  1. package/.env.sample +28 -0
  2. package/compose.yml +28 -0
  3. package/dist/client/index.ts +1 -0
  4. package/dist/client/stores/useMyProfileStore.ts +114 -0
  5. package/dist/client/stores/useOrgDashboardStore.ts +131 -0
  6. package/dist/client/stores/useVenueDetailStore.ts +117 -0
  7. package/dist/client/ws.ts +716 -0
  8. package/dist/db/migrations/000_core.sql +92 -0
  9. package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
  10. package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
  11. package/dist/runtime/manifest.json +1562 -0
  12. package/docs/README.md +293 -36
  13. package/docs/feature-requests/applyPatch-bug-report.md +85 -0
  14. package/docs/feature-requests/connection-ready-profile.md +57 -0
  15. package/docs/feature-requests/hidden-bug-report.md +111 -0
  16. package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
  17. package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
  18. package/docs/feature-requests/todo.md +146 -0
  19. package/docs/for_ai.md +641 -0
  20. package/docs/project-setup.md +432 -0
  21. package/examples/blog.ts +50 -0
  22. package/examples/invalid.ts +18 -0
  23. package/examples/venues.js +485 -0
  24. package/package.json +23 -60
  25. package/src/cli/codegen/client.ts +99 -0
  26. package/src/cli/codegen/manifest.ts +95 -0
  27. package/src/cli/codegen/pinia.ts +174 -0
  28. package/src/cli/codegen/realtime.ts +58 -0
  29. package/src/cli/codegen/sql.ts +698 -0
  30. package/src/cli/codegen/subscribable_sql.ts +547 -0
  31. package/src/cli/codegen/subscribable_store.ts +184 -0
  32. package/src/cli/codegen/types.ts +142 -0
  33. package/src/cli/compiler/analyzer.ts +52 -0
  34. package/src/cli/compiler/graph_rules.ts +251 -0
  35. package/src/cli/compiler/ir.ts +233 -0
  36. package/src/cli/compiler/loader.ts +132 -0
  37. package/src/cli/compiler/permissions.ts +227 -0
  38. package/src/cli/index.ts +164 -0
  39. package/src/client/index.ts +1 -0
  40. package/src/client/ws.ts +286 -0
  41. package/src/create/.env.example +8 -0
  42. package/src/create/README.md +101 -0
  43. package/src/create/compose.yml +14 -0
  44. package/src/create/domain.ts +153 -0
  45. package/src/create/package.json +24 -0
  46. package/src/create/server.ts +18 -0
  47. package/src/create/setup.sh +11 -0
  48. package/src/create/tsconfig.json +15 -0
  49. package/src/runtime/auth.ts +39 -0
  50. package/src/runtime/db.ts +33 -0
  51. package/src/runtime/errors.ts +51 -0
  52. package/src/runtime/index.ts +98 -0
  53. package/src/runtime/js_functions.ts +63 -0
  54. package/src/runtime/manifest_loader.ts +29 -0
  55. package/src/runtime/namespace.ts +483 -0
  56. package/src/runtime/server.ts +87 -0
  57. package/src/runtime/ws.ts +197 -0
  58. package/src/shared/ir.ts +197 -0
  59. package/tests/client.test.ts +38 -0
  60. package/tests/codegen.test.ts +71 -0
  61. package/tests/compiler.test.ts +45 -0
  62. package/tests/graph_rules.test.ts +173 -0
  63. package/tests/integration/db.test.ts +174 -0
  64. package/tests/integration/e2e.test.ts +65 -0
  65. package/tests/integration/features.test.ts +922 -0
  66. package/tests/integration/full_stack.test.ts +262 -0
  67. package/tests/integration/setup.ts +45 -0
  68. package/tests/ir.test.ts +32 -0
  69. package/tests/namespace.test.ts +395 -0
  70. package/tests/permissions.test.ts +55 -0
  71. package/tests/pinia.test.ts +48 -0
  72. package/tests/realtime.test.ts +22 -0
  73. package/tests/runtime.test.ts +80 -0
  74. package/tests/subscribable_gen.test.ts +72 -0
  75. package/tests/subscribable_reactivity.test.ts +258 -0
  76. package/tests/venues_gen.test.ts +25 -0
  77. package/tsconfig.json +20 -0
  78. package/tsconfig.tsbuildinfo +1 -0
  79. package/README.md +0 -90
  80. package/bin/cli.js +0 -727
  81. package/docs/compiler/ADVANCED_FILTERS.md +0 -183
  82. package/docs/compiler/CODING_STANDARDS.md +0 -415
  83. package/docs/compiler/COMPARISON.md +0 -673
  84. package/docs/compiler/QUICKSTART.md +0 -326
  85. package/docs/compiler/README.md +0 -134
  86. package/docs/examples/README.md +0 -38
  87. package/docs/examples/blog.sql +0 -160
  88. package/docs/examples/venue-detail-simple.sql +0 -8
  89. package/docs/examples/venue-detail-subscribable.sql +0 -45
  90. package/docs/for-ai/claude-guide.md +0 -1210
  91. package/docs/getting-started/quickstart.md +0 -125
  92. package/docs/getting-started/subscriptions-quick-start.md +0 -203
  93. package/docs/getting-started/tutorial.md +0 -1104
  94. package/docs/guides/atomic-updates.md +0 -299
  95. package/docs/guides/client-stores.md +0 -730
  96. package/docs/guides/composite-primary-keys.md +0 -158
  97. package/docs/guides/custom-functions.md +0 -362
  98. package/docs/guides/drop-semantics.md +0 -554
  99. package/docs/guides/field-defaults.md +0 -240
  100. package/docs/guides/interpreter-vs-compiler.md +0 -237
  101. package/docs/guides/many-to-many.md +0 -929
  102. package/docs/guides/subscriptions.md +0 -537
  103. package/docs/reference/api.md +0 -1373
  104. package/docs/reference/client.md +0 -224
  105. package/src/client/stores/index.js +0 -8
  106. package/src/client/stores/useAppStore.js +0 -285
  107. package/src/client/stores/useWsStore.js +0 -289
  108. package/src/client/ws.js +0 -762
  109. package/src/compiler/cli/compile-example.js +0 -33
  110. package/src/compiler/cli/compile-subscribable.js +0 -43
  111. package/src/compiler/cli/debug-compile.js +0 -44
  112. package/src/compiler/cli/debug-parse.js +0 -26
  113. package/src/compiler/cli/debug-path-parser.js +0 -18
  114. package/src/compiler/cli/debug-subscribable-parser.js +0 -21
  115. package/src/compiler/cli/index.js +0 -174
  116. package/src/compiler/codegen/auth-codegen.js +0 -153
  117. package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
  118. package/src/compiler/codegen/graph-rules-codegen.js +0 -450
  119. package/src/compiler/codegen/notification-codegen.js +0 -232
  120. package/src/compiler/codegen/operation-codegen.js +0 -1382
  121. package/src/compiler/codegen/permission-codegen.js +0 -318
  122. package/src/compiler/codegen/subscribable-codegen.js +0 -827
  123. package/src/compiler/compiler.js +0 -371
  124. package/src/compiler/index.js +0 -11
  125. package/src/compiler/parser/entity-parser.js +0 -440
  126. package/src/compiler/parser/path-parser.js +0 -290
  127. package/src/compiler/parser/subscribable-parser.js +0 -244
  128. package/src/database/dzql-core.sql +0 -161
  129. package/src/database/migrations/001_schema.sql +0 -60
  130. package/src/database/migrations/002_functions.sql +0 -890
  131. package/src/database/migrations/003_operations.sql +0 -1135
  132. package/src/database/migrations/004_search.sql +0 -581
  133. package/src/database/migrations/005_entities.sql +0 -730
  134. package/src/database/migrations/006_auth.sql +0 -94
  135. package/src/database/migrations/007_events.sql +0 -133
  136. package/src/database/migrations/008_hello.sql +0 -18
  137. package/src/database/migrations/008a_meta.sql +0 -172
  138. package/src/database/migrations/009_subscriptions.sql +0 -240
  139. package/src/database/migrations/010_atomic_updates.sql +0 -157
  140. package/src/database/migrations/010_fix_m2m_events.sql +0 -94
  141. package/src/index.js +0 -40
  142. package/src/server/api.js +0 -9
  143. package/src/server/db.js +0 -442
  144. package/src/server/index.js +0 -317
  145. package/src/server/logger.js +0 -259
  146. package/src/server/mcp.js +0 -594
  147. package/src/server/meta-route.js +0 -251
  148. package/src/server/namespace.js +0 -292
  149. package/src/server/subscriptions.js +0 -351
  150. 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
+ }