dzql 0.6.37 → 0.7.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.
@@ -1,4 +1,4 @@
1
- import type { ColumnInfo } from "./types.js";
1
+ import type { ColumnInfo, EntityIR } from "./types.js";
2
2
 
3
3
  /**
4
4
  * Generate a jsonb_build_object expression that excludes hidden fields.
@@ -17,6 +17,21 @@ export function buildVisibleJsonb(alias: string, columns: ColumnInfo[], hidden:
17
17
  return `jsonb_build_object(${pairs})`;
18
18
  }
19
19
 
20
+ /**
21
+ * Project a RELATED entity (FK/M2M expansion target) through its own visible
22
+ * columns, so a related object never leaks the target's hidden fields (e.g. a
23
+ * post's `author` must not expose users.password_hash). Falls back to
24
+ * to_jsonb(alias.*) only when the target entity is unknown to the generator.
25
+ * @param alias - Table alias the related row is selected under (e.g. 't', 'target')
26
+ * @param targetEntity - IR of the related entity, if available
27
+ */
28
+ export function buildVisibleJsonbForEntity(alias: string, targetEntity?: EntityIR): string {
29
+ if (!targetEntity) {
30
+ return `to_jsonb(${alias}.*)`;
31
+ }
32
+ return buildVisibleJsonb(alias, targetEntity.columns, targetEntity.hidden || []);
33
+ }
34
+
20
35
  /**
21
36
  * Get the SQL cast for a column type
22
37
  */
@@ -9,7 +9,8 @@
9
9
  */
10
10
 
11
11
  import type { SubscribableIR, IncludeIR, EntityIR } from "../../shared/ir.js";
12
- import { parsePath } from "../compiler/paths.js";
12
+ import { parsePath, PathCompileError } from "../compiler/paths.js";
13
+ import { compileRuleList } from "../compiler/permissions.js";
13
14
 
14
15
  export function generateSubscribableSQL(name: string, sub: SubscribableIR, entities: Record<string, EntityIR> = {}): string {
15
16
  // Validate: if root.key is set, it must exist in params or start with '@'
@@ -28,6 +29,7 @@ export function generateSubscribableSQL(name: string, sub: SubscribableIR, entit
28
29
  sections.push(generateHeader(name, sub));
29
30
  sections.push(generateCanSubscribeFunction(name, sub));
30
31
  sections.push(generateGetFunction(name, sub, entities));
32
+ sections.push(generateCanReceiveFunction(name, sub, entities));
31
33
  sections.push(generateAffectedKeysFunction(name, sub));
32
34
 
33
35
  return sections.join('\n\n');
@@ -43,13 +45,11 @@ function generateHeader(name: string, sub: SubscribableIR): string {
43
45
  }
44
46
 
45
47
  function generateCanSubscribeFunction(name: string, sub: SubscribableIR): string {
46
- const subscribePaths = sub.canSubscribe || [];
48
+ const rules = sub.canSubscribe || [];
47
49
  const rootKey = sub.root.key;
48
50
  const isList = !rootKey; // No key means it's a list subscribable
49
51
 
50
- // If no paths, it's public
51
- if (subscribePaths.length === 0) {
52
- return `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
52
+ const header = `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
53
53
  p_user_id INT,
54
54
  p_params JSONB
55
55
  ) RETURNS BOOLEAN
@@ -57,45 +57,40 @@ LANGUAGE plpgsql
57
57
  STABLE
58
58
  SECURITY DEFINER
59
59
  SET search_path = dzql_v2, public
60
- AS $$
60
+ AS $$`;
61
+
62
+ // No rules: nobody may subscribe. Use TRUE for public, AUTHENTICATED for any signed-in user.
63
+ if (rules.length === 0) {
64
+ return `${header}
61
65
  BEGIN
62
- RETURN TRUE; -- Public access
66
+ RETURN FALSE; -- canSubscribe is empty: nobody may subscribe
63
67
  END;
64
68
  $$;`;
65
69
  }
66
70
 
67
- // List subscribables with permission paths: check authentication only
68
- // (Row-level filtering happens in the query, not the permission check)
71
+ // A list subscribable has no root row for a path to start from, so only
72
+ // literal rules (TRUE, FALSE, AUTHENTICATED) can be enforced. Anything else is
73
+ // a build error rather than a silent fallback.
69
74
  if (isList) {
70
- return `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
71
- p_user_id INT,
72
- p_params JSONB
73
- ) RETURNS BOOLEAN
74
- LANGUAGE plpgsql
75
- STABLE
76
- SECURITY DEFINER
77
- SET search_path = dzql_v2, public
78
- AS $$
75
+ for (const rule of rules) {
76
+ if (!parsePath(rule).literal) {
77
+ throw new PathCompileError(
78
+ `${name}.canSubscribe: a list subscribable has no root row to follow a path from; use TRUE, FALSE or AUTHENTICATED`,
79
+ rule
80
+ );
81
+ }
82
+ }
83
+ return `${header}
79
84
  BEGIN
80
- -- List subscribable: user must be authenticated
81
- -- Row-level access is filtered in the get function
82
- RETURN p_user_id IS NOT NULL;
85
+ RETURN (${compileRuleList(name, rules)});
83
86
  END;
84
87
  $$;`;
85
88
  }
86
89
 
87
- // Single-entity subscribable: check permissions against specific root entity
88
- // Generate permission checks using RECORD dot notation
89
- // Pass the root key so we can map param references to root entity's id
90
- const compiledChecks = subscribePaths.map(path =>
91
- compileSubscribePermission(sub.root.entity, path, rootKey)
92
- );
93
-
94
- // If all checks compile to FALSE (unsupported paths), fall back to authenticated users
95
- const allFalse = compiledChecks.every(c => c === 'FALSE');
96
- const checks = allFalse
97
- ? 'p_user_id IS NOT NULL -- Fallback: multi-level paths not yet supported'
98
- : compiledChecks.join(' OR\n ');
90
+ // Single-entity subscribable: rules are checked against the root row (v_root).
91
+ // A rule's reference to the key param means the root's id.
92
+ const fieldMap: Record<string, string> = rootKey.startsWith('@') ? {} : { [rootKey]: 'id' };
93
+ const checks = compileRuleList(name, rules, { kind: 'row', name: 'v_root', fieldMap });
99
94
 
100
95
  // Extract params
101
96
  const paramNames = Object.keys(sub.params);
@@ -109,15 +104,7 @@ $$;`;
109
104
  ? `p_${rootKey.slice(1)}` // @user_id -> p_user_id
110
105
  : `v_${rootKey}`; // venue_id -> v_venue_id
111
106
 
112
- return `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
113
- p_user_id INT,
114
- p_params JSONB
115
- ) RETURNS BOOLEAN
116
- LANGUAGE plpgsql
117
- STABLE
118
- SECURITY DEFINER
119
- SET search_path = dzql_v2, public
120
- AS $$
107
+ return `${header}
121
108
  DECLARE
122
109
  ${paramDecls}
123
110
  v_root RECORD;
@@ -143,94 +130,91 @@ $$;`;
143
130
  }
144
131
 
145
132
  /**
146
- * Compile permission for subscribable can_subscribe function.
147
- * Uses RECORD dot notation since v_root is a RECORD, not JSONB.
148
- * Supports unlimited traversal depth using the shared path parser.
133
+ * <name>_can_receive(p_user_id, p_params, p_table, p_op, p_data): may this
134
+ * subscriber be pushed this change? The runtime calls it before every push.
135
+ * Returns 'row' (send the change), 'pk' (send only its key: a delete), or
136
+ * NULL (send nothing).
149
137
  *
150
- * @param entityName - The root entity name
151
- * @param rule - The permission rule (e.g., "@org_id->acts_for[org_id=$]{active}.user_id")
152
- * @param rootKey - The param key used to look up the root entity (e.g., "org_id")
153
- * When @field matches rootKey, it maps to v_root.id (the root entity's PK)
138
+ * - Nobody who fails can_subscribe receives anything.
139
+ * - A single-document subscription: canSubscribe is the whole gate.
140
+ * - A list: canSubscribe can only be a literal, so rows are filtered here, the
141
+ * same way the list query filters them. A root row needs the root's view
142
+ * rules; an included row must appear in one of the subscriber's documents.
143
+ * An included row that was deleted is in no document any more, so its
144
+ * delete goes to every subscriber as its key alone, which is all a client
145
+ * uses to remove it.
154
146
  */
155
- function compileSubscribePermission(entityName: string, rule: string, rootKey: string = ''): string {
156
- // Helper to resolve field reference - if field matches rootKey param, use 'id' instead
157
- const resolveField = (field: string): string => {
158
- return field === rootKey ? 'id' : field;
159
- };
160
-
161
- // Parse the path using the shared parser
162
- const parsed = parsePath(rule);
163
-
164
- if (!parsed.isValid) {
165
- console.warn(`[Compiler] Warning: Invalid permission path '${rule}': ${parsed.error}`);
166
- return 'FALSE';
167
- }
168
-
169
- // TRUE/FALSE literals
170
- if (parsed.hops.length === 0) {
171
- return rule === 'TRUE' || rule === 'true' ? 'TRUE' : 'FALSE';
172
- }
173
-
174
- // Simple field reference: @org_id implies checking acts_for membership
175
- if (parsed.hops.length === 1 && parsed.hops[0].type === 'field') {
176
- const field = parsed.hops[0].field!;
177
- const rootField = resolveField(field);
178
- return `EXISTS (SELECT 1 FROM acts_for WHERE acts_for.${field} = v_root.${rootField} AND acts_for.user_id = p_user_id AND acts_for.active)`;
179
- }
180
-
181
- // Traversal path - build EXISTS with nested subqueries for unlimited hops
182
- // Start with the field from v_root
183
- const startField = parsed.startField!;
184
- const rootField = resolveField(startField);
185
-
186
- // Build value expression starting from v_root
187
- let valueExpr = `v_root.${rootField}`;
188
-
189
- // Process intermediate hops (all but first field and last final)
190
- for (let i = 1; i < parsed.hops.length - 1; i++) {
191
- const hop = parsed.hops[i];
192
- if (hop.type === 'table' && hop.table && hop.outputField) {
193
- valueExpr = `(SELECT ${hop.outputField} FROM ${hop.table} WHERE id = ${valueExpr})`;
194
- }
195
- }
196
-
197
- // Final hop - build EXISTS check
198
- const finalHop = parsed.hops[parsed.hops.length - 1];
199
- if (finalHop.type !== 'final' || !finalHop.table) {
200
- return 'FALSE';
201
- }
202
-
203
- const conditions: string[] = [];
147
+ function generateCanReceiveFunction(name: string, sub: SubscribableIR, entities: Record<string, EntityIR>): string {
148
+ const isList = !sub.root.key;
149
+ const header = `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_receive(
150
+ p_user_id INT,
151
+ p_params JSONB,
152
+ p_table TEXT,
153
+ p_op TEXT,
154
+ p_data JSONB
155
+ ) RETURNS TEXT
156
+ LANGUAGE plpgsql
157
+ STABLE
158
+ SECURITY DEFINER
159
+ SET search_path = dzql_v2, public
160
+ AS $$
161
+ BEGIN
162
+ IF NOT dzql_v2.${name}_can_subscribe(p_user_id, p_params) THEN
163
+ RETURN NULL;
164
+ END IF;
165
+ `;
166
+ const kind = `CASE WHEN p_op = 'delete' THEN 'pk' ELSE 'row' END`;
204
167
 
205
- // Join condition
206
- if (finalHop.filter && finalHop.filter.value === '$') {
207
- conditions.push(`${finalHop.table}.${finalHop.filter.field} = ${valueExpr}`);
208
- } else {
209
- conditions.push(`${finalHop.table}.id = ${valueExpr}`);
168
+ if (!isList) {
169
+ return `${header}
170
+ RETURN ${kind};
171
+ END;
172
+ $$;`;
210
173
  }
211
174
 
212
- // User check
213
- if (finalHop.outputField === 'user_id') {
214
- conditions.push(`${finalHop.table}.user_id = p_user_id`);
215
- } else if (finalHop.outputField) {
216
- conditions.push(`${finalHop.table}.${finalHop.outputField} = p_user_id`);
217
- }
175
+ const root = sub.root.entity;
176
+ const viewRules = compileRuleList(root, entities[root]?.permissions?.view, { kind: 'jsonb', name: 'p_data' });
218
177
 
219
- // Additional conditions from {active} or {role=admin}
220
- for (const cond of finalHop.conditions || []) {
221
- if (cond.type === 'boolean') {
222
- conditions.push(`${finalHop.table}.${cond.field} = true`);
223
- } else if (cond.type === 'equality') {
224
- if (cond.value === 'NULL') {
225
- conditions.push(`${finalHop.table}.${cond.field} IS NULL`);
226
- } else {
227
- const value = cond.value!.match(/^\d+$/) ? cond.value : `'${cond.value}'`;
228
- conditions.push(`${finalHop.table}.${cond.field} = ${value}`);
229
- }
178
+ // Where each included table sits in a list document: root columns || { rel: ... }
179
+ const pathsByTable: Record<string, string[]> = {};
180
+ const collect = (incl: Record<string, IncludeIR>, prefix: string) => {
181
+ for (const [rel, cfg] of Object.entries(incl || {})) {
182
+ const path = `${prefix}."${rel}"`;
183
+ (pathsByTable[cfg.entity] ??= []).push(path);
184
+ if (cfg.includes) collect(cfg.includes, path);
230
185
  }
231
- }
186
+ };
187
+ collect(sub.includes, '$');
188
+ delete pathsByTable[root];
189
+
190
+ const includeCases = Object.entries(pathsByTable).map(([table, paths]) => {
191
+ const shown = paths
192
+ .map(path => `jsonb_path_exists(d, '${path} ? (@.id == $id)', jsonb_build_object('id', p_data->'id'))`)
193
+ .join(' OR ');
194
+ return ` IF p_table = '${table}' THEN
195
+ IF p_op = 'delete' THEN
196
+ RETURN 'pk';
197
+ END IF;
198
+ IF EXISTS (SELECT 1 FROM dzql_v2.get_${name}(p_params, p_user_id) d WHERE ${shown}) THEN
199
+ RETURN 'row';
200
+ END IF;
201
+ RETURN NULL;
202
+ END IF;
203
+ `;
204
+ }).join('\n');
205
+
206
+ return `${header}
207
+ IF p_table = '${root}' THEN
208
+ IF ${viewRules} THEN
209
+ RETURN ${kind};
210
+ END IF;
211
+ RETURN NULL;
212
+ END IF;
232
213
 
233
- return `EXISTS (SELECT 1 FROM ${finalHop.table} WHERE ${conditions.join(' AND ')})`;
214
+ ${includeCases}
215
+ RETURN NULL;
216
+ END;
217
+ $$;`;
234
218
  }
235
219
 
236
220
  /** Column info from EntityIR */
@@ -307,6 +291,13 @@ function generateGetFunction(name: string, sub: SubscribableIR, entities: Record
307
291
  }
308
292
  }
309
293
  }
294
+ if (isList) {
295
+ // canSubscribe on a list can only be a literal, so it can't tell rows apart.
296
+ // Return just the rows the subscriber may view: the root's view rules,
297
+ // checked against each root row ([] shows nobody any row).
298
+ const viewRules = entities[sub.root.entity]?.permissions?.view;
299
+ whereConditions.push(`(${compileRuleList(sub.root.entity, viewRules, { kind: 'row', name: 'root' })})`);
300
+ }
310
301
  const whereClause = whereConditions.length > 0
311
302
  ? `WHERE ${whereConditions.join(' AND ')}`
312
303
  : '';
@@ -334,9 +325,10 @@ ${paramExtracts}
334
325
  RAISE EXCEPTION 'permission_denied';
335
326
  END IF;
336
327
 
337
- -- Return stream of documents
328
+ -- Return stream of documents (root projected to visible columns; ::jsonb so
329
+ -- the || merge works even when the projection is row_to_json)
338
330
  RETURN QUERY
339
- SELECT to_jsonb(root.*) || jsonb_build_object(${relationSelects ? relationSelects.slice(1) : ''})
331
+ SELECT (${rootSelectExpr})::jsonb || jsonb_build_object(${relationSelects ? relationSelects.slice(1) : ''})
340
332
  FROM ${sub.root.entity} root
341
333
  ${whereClause};
342
334
  END;
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Domain -> SQL compilation pipeline.
3
+ *
4
+ * The CLI writes these strings to migration files; tests load the same strings
5
+ * into Postgres, so they exercise exactly the SQL the compiler produces.
6
+ */
7
+ import type { DomainConfig } from "../shared/ir.js";
8
+ import { analyzeDomain } from "./compiler/analyzer.js";
9
+ import { generateIR } from "./compiler/ir.js";
10
+ import { validateDomainPaths } from "./compiler/path-validator.js";
11
+ import { generateCoreSQL, generateAuthSQL, generateEntitySQL, generateSchemaSQL } from "./codegen/sql.js";
12
+ import { generateSubscribableSQL, generateComputeAffectedKeysFunction } from "./codegen/subscribable_sql.js";
13
+
14
+ export interface CompiledDomain {
15
+ ir: any;
16
+ coreSQL: string;
17
+ /** Tables, CRUD functions, auth functions and custom functions */
18
+ schemaSQL: string;
19
+ /** Subscribable functions plus compute_affected_keys */
20
+ subscribableSQL: string;
21
+ customFunctionCount: number;
22
+ }
23
+
24
+ export class DomainValidationError extends Error {
25
+ constructor(public errors: string[]) {
26
+ super(`Domain validation failed:\n${errors.map(e => ` - ${e}`).join('\n')}`);
27
+ this.name = 'DomainValidationError';
28
+ }
29
+ }
30
+
31
+ export function compileDomain(domain: DomainConfig): CompiledDomain {
32
+ const errors = analyzeDomain(domain);
33
+ if (errors.length > 0) {
34
+ throw new DomainValidationError(errors);
35
+ }
36
+
37
+ const ir = generateIR(domain);
38
+
39
+ // Every permission, notification and canSubscribe path must compile against
40
+ // this domain. A path that can't is a build error, never FALSE or a fallback.
41
+ const pathErrors = validateDomainPaths(ir);
42
+ if (pathErrors.length > 0) {
43
+ throw new DomainValidationError(pathErrors);
44
+ }
45
+
46
+ const coreSQL = generateCoreSQL();
47
+
48
+ // Topologically sort entities by FK dependencies
49
+ // Entities must be created before entities that reference them
50
+ // Also detects circular FKs that need deferred constraint creation
51
+ const { sorted: sortedEntityNames, circularFKs } = topologicalSortEntities(ir.entities);
52
+
53
+ // Build set of circular FK columns for stripping during schema generation
54
+ const circularFKSet = new Set(circularFKs.map(fk => `${fk.table}.${fk.column}`));
55
+
56
+ const entitySQL: string[] = [];
57
+ for (const name of sortedEntityNames) {
58
+ const entityIR = ir.entities[name];
59
+ // Pass circular FK info so schema generation can strip those constraints
60
+ entitySQL.push(generateSchemaSQL(name, entityIR, circularFKSet));
61
+ // Skip CRUD generation for unmanaged entities (e.g., junction tables)
62
+ if (entityIR.managed !== false) {
63
+ entitySQL.push(generateEntitySQL(name, entityIR, ir.entities));
64
+ } else {
65
+ console.log(`[Compiler] Skipping CRUD for unmanaged entity: ${name}`);
66
+ }
67
+ }
68
+
69
+ // Add deferred FK constraints at the end (for circular dependencies)
70
+ if (circularFKs.length > 0) {
71
+ entitySQL.push(generateDeferredFKConstraints(circularFKs));
72
+ }
73
+
74
+ // Generate subscribable SQL functions
75
+ const subscribableSQL: string[] = [];
76
+ const subscribableNames = Object.keys(ir.subscribables);
77
+ for (const [name, subIR] of Object.entries(ir.subscribables)) {
78
+ console.log(`[Compiler] Generating SQL for subscribable: ${name}`);
79
+ subscribableSQL.push(generateSubscribableSQL(name, subIR as any, ir.entities));
80
+ }
81
+ // Generate central compute_affected_keys function that aggregates all subscribables
82
+ subscribableSQL.push(generateComputeAffectedKeysFunction(subscribableNames));
83
+
84
+ // Collect custom functions SQL
85
+ const customFunctionSQL: string[] = [];
86
+ for (const fn of ir.customFunctions) {
87
+ console.log(`[Compiler] Adding custom function: ${fn.name}`);
88
+ customFunctionSQL.push(fn.sql);
89
+ }
90
+
91
+ // Combine entity SQL with auth functions and custom functions
92
+ // Auth functions must come after schema (they depend on users table)
93
+ // Columns a client may set at registration: those declared in
94
+ // auth.registerParams (beyond the built-in email/password/options), else
95
+ // just the users label field. Everything else is rejected at sign-up.
96
+ const usersEntity = ir.entities.users;
97
+ const declaredRegisterable = Object.keys(ir.auth?.registerParams || {})
98
+ .filter((k) => !["email", "password", "options"].includes(k));
99
+ const registerableColumns = declaredRegisterable.length > 0
100
+ ? declaredRegisterable
101
+ : (usersEntity && usersEntity.labelField && usersEntity.labelField !== "id"
102
+ ? [usersEntity.labelField]
103
+ : []);
104
+ const authSQL = generateAuthSQL(registerableColumns);
105
+ let schemaSQL = entitySQL.join('\n');
106
+ schemaSQL += '\n\n-- Auth Functions\n' + authSQL;
107
+ if (customFunctionSQL.length > 0) {
108
+ schemaSQL += '\n\n-- Custom Functions\n' + customFunctionSQL.join('\n\n');
109
+ }
110
+
111
+ return {
112
+ ir,
113
+ coreSQL,
114
+ schemaSQL,
115
+ subscribableSQL: subscribableSQL.join('\n\n'),
116
+ customFunctionCount: customFunctionSQL.length,
117
+ };
118
+ }
119
+
120
+ /** FK info for dependency analysis */
121
+ interface FKInfo {
122
+ table: string;
123
+ column: string;
124
+ referencedTable: string;
125
+ fullConstraint: string; // e.g., "int NOT NULL REFERENCES users(id)"
126
+ }
127
+
128
+ /** Result of topological sort with cycle detection */
129
+ interface SortResult {
130
+ sorted: string[];
131
+ circularFKs: FKInfo[];
132
+ }
133
+
134
+ /**
135
+ * Topologically sort entities based on FK dependencies.
136
+ * Entities that are referenced by others come first.
137
+ * Uses Kahn's algorithm for topological sorting.
138
+ * Returns both sorted order and any circular FK constraints that need deferred creation.
139
+ */
140
+ function topologicalSortEntities(entities: Record<string, any>): SortResult {
141
+ const entityNames = Object.keys(entities);
142
+
143
+ // Build dependency graph: entity -> entities it depends on (references)
144
+ const dependencies: Record<string, Set<string>> = {};
145
+ const dependents: Record<string, Set<string>> = {};
146
+
147
+ // Track all FK info for cycle detection
148
+ const allFKs: FKInfo[] = [];
149
+
150
+ for (const name of entityNames) {
151
+ dependencies[name] = new Set();
152
+ dependents[name] = new Set();
153
+ }
154
+
155
+ // Parse REFERENCES from column types
156
+ for (const name of entityNames) {
157
+ const entity = entities[name];
158
+ for (const col of entity.columns || []) {
159
+ const match = col.type?.match(/REFERENCES\s+(\w+)/i);
160
+ if (match) {
161
+ const referencedEntity = match[1];
162
+ // Only track dependencies to entities we're managing
163
+ if (entityNames.includes(referencedEntity)) {
164
+ dependencies[name].add(referencedEntity);
165
+ dependents[referencedEntity].add(name);
166
+ allFKs.push({
167
+ table: name,
168
+ column: col.name,
169
+ referencedTable: referencedEntity,
170
+ fullConstraint: col.type
171
+ });
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ // Kahn's algorithm
178
+ const result: string[] = [];
179
+ const noIncoming: string[] = [];
180
+ const remainingDeps: Record<string, Set<string>> = {};
181
+
182
+ // Copy dependencies for mutation
183
+ for (const name of entityNames) {
184
+ remainingDeps[name] = new Set(dependencies[name]);
185
+ }
186
+
187
+ // Find entities with no dependencies (no incoming edges)
188
+ for (const name of entityNames) {
189
+ if (remainingDeps[name].size === 0) {
190
+ noIncoming.push(name);
191
+ }
192
+ }
193
+
194
+ while (noIncoming.length > 0) {
195
+ const node = noIncoming.shift()!;
196
+ result.push(node);
197
+
198
+ // Remove this node from the graph
199
+ for (const dependent of dependents[node]) {
200
+ remainingDeps[dependent].delete(node);
201
+ if (remainingDeps[dependent].size === 0) {
202
+ noIncoming.push(dependent);
203
+ }
204
+ }
205
+ }
206
+
207
+ // Detect circular FKs
208
+ const circularFKs: FKInfo[] = [];
209
+ if (result.length !== entityNames.length) {
210
+ const cycleEntities = new Set(entityNames.filter(n => !result.includes(n)));
211
+ console.warn(`[Compiler] Warning: Circular FK dependencies detected among: ${[...cycleEntities].join(', ')}`);
212
+
213
+ // Find FKs that are part of the cycle - these need deferred creation
214
+ for (const fk of allFKs) {
215
+ if (cycleEntities.has(fk.table) && cycleEntities.has(fk.referencedTable)) {
216
+ circularFKs.push(fk);
217
+ console.log(`[Compiler] Deferring circular FK: ${fk.table}.${fk.column} -> ${fk.referencedTable}`);
218
+ }
219
+ }
220
+
221
+ // Add remaining entities (cycle members) to result
222
+ result.push(...cycleEntities);
223
+ }
224
+
225
+ return { sorted: result, circularFKs };
226
+ }
227
+
228
+ /**
229
+ * Generate ALTER TABLE statements for deferred circular FK constraints.
230
+ */
231
+ function generateDeferredFKConstraints(circularFKs: FKInfo[]): string {
232
+ if (circularFKs.length === 0) return '';
233
+
234
+ const statements: string[] = [
235
+ '\n-- === DEFERRED FK CONSTRAINTS (for circular dependencies) ===\n'
236
+ ];
237
+
238
+ for (const fk of circularFKs) {
239
+ // Extract the REFERENCES part from the full constraint
240
+ const refMatch = fk.fullConstraint.match(/REFERENCES\s+(\w+)(\([^)]+\))?(\s+ON\s+.+)?/i);
241
+ if (refMatch) {
242
+ const targetTable = refMatch[1];
243
+ const targetCol = refMatch[2] || '(id)';
244
+ const cascadeClause = refMatch[3] || '';
245
+ const constraintName = `fk_${fk.table}_${fk.column}`;
246
+
247
+ statements.push(`ALTER TABLE ${fk.table} ADD CONSTRAINT ${constraintName} FOREIGN KEY (${fk.column}) REFERENCES ${targetTable}${targetCol}${cascadeClause};`);
248
+ }
249
+ }
250
+
251
+ return statements.join('\n');
252
+ }
253
+
254
+ /**
255
+ * Remove REFERENCES clause from a column type for deferred FK creation.
256
+ * Preserves NOT NULL and other modifiers.
257
+ */
258
+ function stripReferences(columnType: string): string {
259
+ // Remove REFERENCES clause and any ON DELETE/UPDATE clauses
260
+ return columnType
261
+ .replace(/\s*REFERENCES\s+\w+(\([^)]+\))?(\s+ON\s+(DELETE|UPDATE)\s+(CASCADE|SET NULL|SET DEFAULT|RESTRICT|NO ACTION))*\s*/gi, ' ')
262
+ .trim();
263
+ }