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.
Files changed (142) 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 +309 -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 +653 -0
  20. package/docs/project-setup.md +456 -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 +166 -0
  39. package/src/client/index.ts +1 -0
  40. package/src/client/ws.ts +286 -0
  41. package/src/runtime/auth.ts +39 -0
  42. package/src/runtime/db.ts +33 -0
  43. package/src/runtime/errors.ts +51 -0
  44. package/src/runtime/index.ts +98 -0
  45. package/src/runtime/js_functions.ts +63 -0
  46. package/src/runtime/manifest_loader.ts +29 -0
  47. package/src/runtime/namespace.ts +483 -0
  48. package/src/runtime/server.ts +87 -0
  49. package/src/runtime/ws.ts +197 -0
  50. package/src/shared/ir.ts +197 -0
  51. package/tests/client.test.ts +38 -0
  52. package/tests/codegen.test.ts +71 -0
  53. package/tests/compiler.test.ts +45 -0
  54. package/tests/graph_rules.test.ts +173 -0
  55. package/tests/integration/db.test.ts +174 -0
  56. package/tests/integration/e2e.test.ts +65 -0
  57. package/tests/integration/features.test.ts +922 -0
  58. package/tests/integration/full_stack.test.ts +262 -0
  59. package/tests/integration/setup.ts +45 -0
  60. package/tests/ir.test.ts +32 -0
  61. package/tests/namespace.test.ts +395 -0
  62. package/tests/permissions.test.ts +55 -0
  63. package/tests/pinia.test.ts +48 -0
  64. package/tests/realtime.test.ts +22 -0
  65. package/tests/runtime.test.ts +80 -0
  66. package/tests/subscribable_gen.test.ts +72 -0
  67. package/tests/subscribable_reactivity.test.ts +258 -0
  68. package/tests/venues_gen.test.ts +25 -0
  69. package/tsconfig.json +20 -0
  70. package/tsconfig.tsbuildinfo +1 -0
  71. package/README.md +0 -90
  72. package/bin/cli.js +0 -727
  73. package/docs/compiler/ADVANCED_FILTERS.md +0 -183
  74. package/docs/compiler/CODING_STANDARDS.md +0 -415
  75. package/docs/compiler/COMPARISON.md +0 -673
  76. package/docs/compiler/QUICKSTART.md +0 -326
  77. package/docs/compiler/README.md +0 -134
  78. package/docs/examples/README.md +0 -38
  79. package/docs/examples/blog.sql +0 -160
  80. package/docs/examples/venue-detail-simple.sql +0 -8
  81. package/docs/examples/venue-detail-subscribable.sql +0 -45
  82. package/docs/for-ai/claude-guide.md +0 -1210
  83. package/docs/getting-started/quickstart.md +0 -125
  84. package/docs/getting-started/subscriptions-quick-start.md +0 -203
  85. package/docs/getting-started/tutorial.md +0 -1104
  86. package/docs/guides/atomic-updates.md +0 -299
  87. package/docs/guides/client-stores.md +0 -730
  88. package/docs/guides/composite-primary-keys.md +0 -158
  89. package/docs/guides/custom-functions.md +0 -362
  90. package/docs/guides/drop-semantics.md +0 -554
  91. package/docs/guides/field-defaults.md +0 -240
  92. package/docs/guides/interpreter-vs-compiler.md +0 -237
  93. package/docs/guides/many-to-many.md +0 -929
  94. package/docs/guides/subscriptions.md +0 -537
  95. package/docs/reference/api.md +0 -1373
  96. package/docs/reference/client.md +0 -224
  97. package/src/client/stores/index.js +0 -8
  98. package/src/client/stores/useAppStore.js +0 -285
  99. package/src/client/stores/useWsStore.js +0 -289
  100. package/src/client/ws.js +0 -762
  101. package/src/compiler/cli/compile-example.js +0 -33
  102. package/src/compiler/cli/compile-subscribable.js +0 -43
  103. package/src/compiler/cli/debug-compile.js +0 -44
  104. package/src/compiler/cli/debug-parse.js +0 -26
  105. package/src/compiler/cli/debug-path-parser.js +0 -18
  106. package/src/compiler/cli/debug-subscribable-parser.js +0 -21
  107. package/src/compiler/cli/index.js +0 -174
  108. package/src/compiler/codegen/auth-codegen.js +0 -153
  109. package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
  110. package/src/compiler/codegen/graph-rules-codegen.js +0 -450
  111. package/src/compiler/codegen/notification-codegen.js +0 -232
  112. package/src/compiler/codegen/operation-codegen.js +0 -1382
  113. package/src/compiler/codegen/permission-codegen.js +0 -318
  114. package/src/compiler/codegen/subscribable-codegen.js +0 -827
  115. package/src/compiler/compiler.js +0 -371
  116. package/src/compiler/index.js +0 -11
  117. package/src/compiler/parser/entity-parser.js +0 -440
  118. package/src/compiler/parser/path-parser.js +0 -290
  119. package/src/compiler/parser/subscribable-parser.js +0 -244
  120. package/src/database/dzql-core.sql +0 -161
  121. package/src/database/migrations/001_schema.sql +0 -60
  122. package/src/database/migrations/002_functions.sql +0 -890
  123. package/src/database/migrations/003_operations.sql +0 -1135
  124. package/src/database/migrations/004_search.sql +0 -581
  125. package/src/database/migrations/005_entities.sql +0 -730
  126. package/src/database/migrations/006_auth.sql +0 -94
  127. package/src/database/migrations/007_events.sql +0 -133
  128. package/src/database/migrations/008_hello.sql +0 -18
  129. package/src/database/migrations/008a_meta.sql +0 -172
  130. package/src/database/migrations/009_subscriptions.sql +0 -240
  131. package/src/database/migrations/010_atomic_updates.sql +0 -157
  132. package/src/database/migrations/010_fix_m2m_events.sql +0 -94
  133. package/src/index.js +0 -40
  134. package/src/server/api.js +0 -9
  135. package/src/server/db.js +0 -442
  136. package/src/server/index.js +0 -317
  137. package/src/server/logger.js +0 -259
  138. package/src/server/mcp.js +0 -594
  139. package/src/server/meta-route.js +0 -251
  140. package/src/server/namespace.js +0 -292
  141. package/src/server/subscriptions.js +0 -351
  142. 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
+ }