|
1 | 1 | /** |
2 | | - * cross-relations.ts — Cross-domain relations (declarative definition) |
| 2 | + * cross-relations.ts — Cross-domain relations (declarative blueprint) |
3 | 3 | * |
4 | | - * Creates junction tables and FKs that span multiple schema domains. |
5 | | - * Must run AFTER all individual schema modules so table IDs can be resolved. |
| 4 | + * Creates junction tables and FKs that span multiple schema domains. Must run |
| 5 | + * AFTER all individual domain schemas so the referenced tables already exist. |
| 6 | + * |
| 7 | + * Rides the same declarative provisionBlueprint() path as every other schema |
| 8 | + * here — no raw SQL, no manual table-ID resolution. Every relation carries |
| 9 | + * source_schema_name / target_schema_name = 'app_public' to disambiguate |
| 10 | + * tables like "emails" that also exist in user_identifiers_public. |
6 | 11 | * |
7 | 12 | * M:N junctions: |
8 | 13 | * projects <-> contacts, tasks <-> contacts, tasks <-> projects, goals <-> habits, |
|
12 | 17 | * events <-> notes, tasks <-> notes, contacts <-> memories, |
13 | 18 | * companies <-> memories, |
14 | 19 | * email_threads <-> contacts, emails <-> contacts, emails <-> notes, |
15 | | - * calendar_events <-> contacts, calendar_events <-> notes, calendar_events <-> tasks |
| 20 | + * calendar_events <-> notes, calendar_events <-> tasks, |
| 21 | + * skills <-> tool_definitions |
16 | 22 | * |
17 | | - * BelongsTo (cross-schema): |
18 | | - * emails -> contacts (from_contact_id FK) |
19 | | - * calendar_events -> contacts (organizer_contact_id FK) |
20 | | - * calendar_attendees -> contacts (contact_id FK) |
| 23 | + * BelongsTo (cross-schema FKs): |
| 24 | + * memories -> agents (agent_id), tool_executions -> tool_definitions, |
| 25 | + * expenses -> trips, activity_logs -> habits, |
| 26 | + * emails -> contacts, calendar_events -> contacts, calendar_attendees -> contacts |
21 | 27 | * |
22 | | - * BelongsTo: |
23 | | - * memories -> agents (agent_id FK) |
24 | | - * activity_logs -> habits (habit_id FK) |
| 28 | + * RLS: junctions get RLS enabled by provision_relation; the top-level |
| 29 | + * provision.ts disables RLS globally after all schemas run, so no per- |
| 30 | + * junction cleanup is needed here. |
25 | 31 | */ |
26 | 32 |
|
27 | | -import { requireDatabaseId } from '../helpers'; |
| 33 | +import { |
| 34 | + type BlueprintDefinition, |
| 35 | + type BlueprintRelation, |
| 36 | + provisionBlueprint, |
| 37 | +} from '../blueprint'; |
28 | 38 |
|
29 | | -const databaseId = requireDatabaseId(); |
| 39 | +const SCHEMA = 'app_public'; |
30 | 40 |
|
31 | 41 | // --------------------------------------------------------------------------- |
32 | | -// Declarative relation definitions |
| 42 | +// M:N junctions |
33 | 43 | // --------------------------------------------------------------------------- |
34 | 44 |
|
35 | | -interface M2NRelation { |
36 | | - sourceTable: string; |
37 | | - targetTable: string; |
38 | | - junctionTableName: string; |
39 | | - sourceFieldName: string; |
40 | | - targetFieldName: string; |
41 | | -} |
42 | | - |
43 | | -interface BelongsToRelation { |
44 | | - sourceTable: string; |
45 | | - targetTable: string; |
46 | | - fieldName: string; |
47 | | - deleteAction: string; |
48 | | - isRequired: boolean; |
| 45 | +interface M2NSpec { |
| 46 | + source_table: string; |
| 47 | + target_table: string; |
| 48 | + junction_table_name: string; |
| 49 | + source_field_name: string; |
| 50 | + target_field_name: string; |
49 | 51 | } |
50 | 52 |
|
51 | | -const M2N_RELATIONS: M2NRelation[] = [ |
52 | | - { sourceTable: 'projects', targetTable: 'contacts', junctionTableName: 'project_contacts', sourceFieldName: 'project_id', targetFieldName: 'contact_id' }, |
53 | | - { sourceTable: 'tasks', targetTable: 'contacts', junctionTableName: 'task_contacts', sourceFieldName: 'task_id', targetFieldName: 'contact_id' }, |
54 | | - { sourceTable: 'tasks', targetTable: 'projects', junctionTableName: 'task_projects', sourceFieldName: 'task_id', targetFieldName: 'project_id' }, |
55 | | - { sourceTable: 'goals', targetTable: 'habits', junctionTableName: 'goal_habits', sourceFieldName: 'goal_id', targetFieldName: 'habit_id' }, |
56 | | - { sourceTable: 'goals', targetTable: 'projects', junctionTableName: 'goal_projects', sourceFieldName: 'goal_id', targetFieldName: 'project_id' }, |
57 | | - { sourceTable: 'calendar_events', targetTable: 'contacts', junctionTableName: 'calendar_event_contacts', sourceFieldName: 'calendar_event_id', targetFieldName: 'contact_id' }, |
58 | | - { sourceTable: 'expenses', targetTable: 'contacts', junctionTableName: 'expense_contacts', sourceFieldName: 'expense_id', targetFieldName: 'contact_id' }, |
59 | | - // NOTE: agents <-> rules and agents <-> skills are HasMany in agent.ts (not M2N) |
60 | | - { sourceTable: 'agents', targetTable: 'prompts', junctionTableName: 'agent_prompts', sourceFieldName: 'agent_id', targetFieldName: 'prompt_id' }, |
61 | | - { sourceTable: 'contacts', targetTable: 'notes', junctionTableName: 'contact_notes', sourceFieldName: 'contact_id', targetFieldName: 'note_id' }, |
62 | | - { sourceTable: 'companies', targetTable: 'notes', junctionTableName: 'company_notes', sourceFieldName: 'company_id', targetFieldName: 'note_id' }, |
63 | | - { sourceTable: 'deals', targetTable: 'notes', junctionTableName: 'deal_notes', sourceFieldName: 'deal_id', targetFieldName: 'note_id' }, |
64 | | - { sourceTable: 'events', targetTable: 'notes', junctionTableName: 'event_notes', sourceFieldName: 'event_id', targetFieldName: 'note_id' }, |
65 | | - { sourceTable: 'tasks', targetTable: 'notes', junctionTableName: 'task_notes', sourceFieldName: 'task_id', targetFieldName: 'note_id' }, |
66 | | - { sourceTable: 'contacts', targetTable: 'memories', junctionTableName: 'contact_memories', sourceFieldName: 'contact_id', targetFieldName: 'memory_id' }, |
67 | | - { sourceTable: 'companies', targetTable: 'memories', junctionTableName: 'company_memories', sourceFieldName: 'company_id', targetFieldName: 'memory_id' }, |
68 | | - { sourceTable: 'skills', targetTable: 'tool_definitions', junctionTableName: 'skill_tools', sourceFieldName: 'skill_id', targetFieldName: 'tool_definition_id' }, |
69 | | - |
70 | | - // Email & calendar cross-relations |
71 | | - { sourceTable: 'email_threads', targetTable: 'contacts', junctionTableName: 'thread_participants', sourceFieldName: 'email_thread_id', targetFieldName: 'contact_id' }, |
72 | | - { sourceTable: 'emails', targetTable: 'contacts', junctionTableName: 'email_recipients', sourceFieldName: 'email_id', targetFieldName: 'contact_id' }, |
73 | | - { sourceTable: 'emails', targetTable: 'notes', junctionTableName: 'email_notes', sourceFieldName: 'email_id', targetFieldName: 'note_id' }, |
74 | | - // calendar_events <-> contacts already defined above (line 98) |
75 | | - { sourceTable: 'calendar_events', targetTable: 'notes', junctionTableName: 'calendar_event_notes', sourceFieldName: 'calendar_event_id', targetFieldName: 'note_id' }, |
76 | | - { sourceTable: 'calendar_events', targetTable: 'tasks', junctionTableName: 'calendar_event_tasks', sourceFieldName: 'calendar_event_id', targetFieldName: 'task_id' }, |
77 | | -]; |
78 | | - |
79 | | -const BELONGS_TO_RELATIONS: BelongsToRelation[] = [ |
80 | | - { sourceTable: 'memories', targetTable: 'agents', fieldName: 'agent_id', deleteAction: 'n', isRequired: false }, |
81 | | - // Runtime cross-schema FK |
82 | | - { sourceTable: 'tool_executions', targetTable: 'tool_definitions', fieldName: 'tool_definition_id', deleteAction: 'c', isRequired: true }, |
83 | | - // Life-OS cross-schema FKs |
84 | | - { sourceTable: 'expenses', targetTable: 'trips', fieldName: 'trip_id', deleteAction: 'n', isRequired: false }, |
85 | | - { sourceTable: 'activity_logs', targetTable: 'habits', fieldName: 'habit_id', deleteAction: 'n', isRequired: false }, |
86 | | - // Email & calendar BelongsTo contacts |
87 | | - { sourceTable: 'emails', targetTable: 'contacts', fieldName: 'from_contact_id', deleteAction: 'n', isRequired: false }, |
88 | | - { sourceTable: 'calendar_events', targetTable: 'contacts', fieldName: 'organizer_contact_id', deleteAction: 'n', isRequired: false }, |
89 | | - { sourceTable: 'calendar_attendees', targetTable: 'contacts', fieldName: 'contact_id', deleteAction: 'n', isRequired: false }, |
| 53 | +const M2N: M2NSpec[] = [ |
| 54 | + { source_table: 'projects', target_table: 'contacts', junction_table_name: 'project_contacts', source_field_name: 'project_id', target_field_name: 'contact_id' }, |
| 55 | + { source_table: 'tasks', target_table: 'contacts', junction_table_name: 'task_contacts', source_field_name: 'task_id', target_field_name: 'contact_id' }, |
| 56 | + { source_table: 'tasks', target_table: 'projects', junction_table_name: 'task_projects', source_field_name: 'task_id', target_field_name: 'project_id' }, |
| 57 | + { source_table: 'goals', target_table: 'habits', junction_table_name: 'goal_habits', source_field_name: 'goal_id', target_field_name: 'habit_id' }, |
| 58 | + { source_table: 'goals', target_table: 'projects', junction_table_name: 'goal_projects', source_field_name: 'goal_id', target_field_name: 'project_id' }, |
| 59 | + { source_table: 'calendar_events', target_table: 'contacts', junction_table_name: 'calendar_event_contacts', source_field_name: 'calendar_event_id', target_field_name: 'contact_id' }, |
| 60 | + { source_table: 'expenses', target_table: 'contacts', junction_table_name: 'expense_contacts', source_field_name: 'expense_id', target_field_name: 'contact_id' }, |
| 61 | + { source_table: 'agents', target_table: 'prompts', junction_table_name: 'agent_prompts', source_field_name: 'agent_id', target_field_name: 'prompt_id' }, |
| 62 | + { source_table: 'contacts', target_table: 'notes', junction_table_name: 'contact_notes', source_field_name: 'contact_id', target_field_name: 'note_id' }, |
| 63 | + { source_table: 'companies', target_table: 'notes', junction_table_name: 'company_notes', source_field_name: 'company_id', target_field_name: 'note_id' }, |
| 64 | + { source_table: 'deals', target_table: 'notes', junction_table_name: 'deal_notes', source_field_name: 'deal_id', target_field_name: 'note_id' }, |
| 65 | + { source_table: 'events', target_table: 'notes', junction_table_name: 'event_notes', source_field_name: 'event_id', target_field_name: 'note_id' }, |
| 66 | + { source_table: 'tasks', target_table: 'notes', junction_table_name: 'task_notes', source_field_name: 'task_id', target_field_name: 'note_id' }, |
| 67 | + { source_table: 'contacts', target_table: 'memories', junction_table_name: 'contact_memories', source_field_name: 'contact_id', target_field_name: 'memory_id' }, |
| 68 | + { source_table: 'companies', target_table: 'memories', junction_table_name: 'company_memories', source_field_name: 'company_id', target_field_name: 'memory_id' }, |
| 69 | + { source_table: 'skills', target_table: 'tool_definitions', junction_table_name: 'skill_tools', source_field_name: 'skill_id', target_field_name: 'tool_definition_id' }, |
| 70 | + { source_table: 'email_threads', target_table: 'contacts', junction_table_name: 'thread_participants', source_field_name: 'email_thread_id', target_field_name: 'contact_id' }, |
| 71 | + { source_table: 'emails', target_table: 'contacts', junction_table_name: 'email_recipients', source_field_name: 'email_id', target_field_name: 'contact_id' }, |
| 72 | + { source_table: 'emails', target_table: 'notes', junction_table_name: 'email_notes', source_field_name: 'email_id', target_field_name: 'note_id' }, |
| 73 | + { source_table: 'calendar_events', target_table: 'notes', junction_table_name: 'calendar_event_notes', source_field_name: 'calendar_event_id', target_field_name: 'note_id' }, |
| 74 | + { source_table: 'calendar_events', target_table: 'tasks', junction_table_name: 'calendar_event_tasks', source_field_name: 'calendar_event_id', target_field_name: 'task_id' }, |
90 | 75 | ]; |
91 | 76 |
|
92 | 77 | // --------------------------------------------------------------------------- |
93 | | -// Main |
| 78 | +// BelongsTo FKs (cross-schema) |
94 | 79 | // --------------------------------------------------------------------------- |
95 | 80 |
|
96 | | -async function main() { |
97 | | - console.log('\n🔗 Cross-Domain Relations\n'); |
98 | | - console.log(` ${M2N_RELATIONS.length} M:N junctions + ${BELONGS_TO_RELATIONS.length} BelongsTo FKs\n`); |
99 | | - |
100 | | - // Use direct SQL provision_relation calls (bypasses both GraphQL SDK |
101 | | - // triggers and blueprint validation that requires non-empty tables array) |
102 | | - const { Pool } = await import('pg'); |
103 | | - const pool = new Pool({ database: process.env.PGDATABASE || 'constructive' }); |
104 | | - try { |
105 | | - await pool.query('SET statement_timeout = \'600s\''); |
106 | | - |
107 | | - // Resolve app_public schema id (needed to disambiguate tables like |
108 | | - // 'emails' which also exist in user_identifiers_public) |
109 | | - const { rows: schemaRows } = await pool.query( |
110 | | - `SELECT id FROM metaschema_public.schema |
111 | | - WHERE database_id = $1 AND name = 'app_public' LIMIT 1`, |
112 | | - [databaseId] |
113 | | - ); |
114 | | - const appSchemaId = schemaRows[0]?.id; |
115 | | - if (!appSchemaId) throw new Error('Could not resolve app_public schema id'); |
| 81 | +interface BelongsToSpec { |
| 82 | + source_table: string; |
| 83 | + target_table: string; |
| 84 | + field_name: string; |
| 85 | + delete_action: 'c' | 'r' | 'n' | 'd' | 'a'; |
| 86 | + is_required: boolean; |
| 87 | +} |
116 | 88 |
|
117 | | - // Helper: resolve table id scoped to app_public (avoids ambiguity |
118 | | - // when a table name like 'emails' exists in multiple schemas) |
119 | | - async function resolveTableId(name: string): Promise<string> { |
120 | | - const { rows } = await pool.query( |
121 | | - `SELECT id FROM metaschema_public."table" |
122 | | - WHERE database_id = $1 AND schema_id = $2 AND name = $3 LIMIT 1`, |
123 | | - [databaseId, appSchemaId, name] |
124 | | - ); |
125 | | - if (!rows[0]?.id) throw new Error(`Table '${name}' not found in app_public`); |
126 | | - return rows[0].id; |
127 | | - } |
| 89 | +const BELONGS_TO: BelongsToSpec[] = [ |
| 90 | + { source_table: 'memories', target_table: 'agents', field_name: 'agent_id', delete_action: 'n', is_required: false }, |
| 91 | + { source_table: 'tool_executions', target_table: 'tool_definitions', field_name: 'tool_definition_id', delete_action: 'c', is_required: true }, |
| 92 | + { source_table: 'expenses', target_table: 'trips', field_name: 'trip_id', delete_action: 'n', is_required: false }, |
| 93 | + { source_table: 'activity_logs', target_table: 'habits', field_name: 'habit_id', delete_action: 'n', is_required: false }, |
| 94 | + { source_table: 'emails', target_table: 'contacts', field_name: 'from_contact_id', delete_action: 'n', is_required: false }, |
| 95 | + { source_table: 'calendar_events', target_table: 'contacts', field_name: 'organizer_contact_id', delete_action: 'n', is_required: false }, |
| 96 | + { source_table: 'calendar_attendees', target_table: 'contacts', field_name: 'contact_id', delete_action: 'n', is_required: false }, |
| 97 | +]; |
128 | 98 |
|
129 | | - // -- M:N junctions -- |
130 | | - for (const rel of M2N_RELATIONS) { |
131 | | - try { |
132 | | - const sourceId = await resolveTableId(rel.sourceTable); |
133 | | - const targetId = await resolveTableId(rel.targetTable); |
134 | | - await pool.query( |
135 | | - `SELECT metaschema_modules_public.provision_relation( |
136 | | - database_id := $1::uuid, |
137 | | - relation_type := 'RelationManyToMany', |
138 | | - source_table_id := $2::uuid, |
139 | | - target_table_id := $3::uuid, |
140 | | - junction_table_name := $4, |
141 | | - source_field_name := $5, |
142 | | - target_field_name := $6, |
143 | | - is_required := false, |
144 | | - grant_roles := ARRAY[]::text[], |
145 | | - grants := '[]'::jsonb, |
146 | | - policies := '[]'::jsonb |
147 | | - )`, |
148 | | - [databaseId, sourceId, targetId, rel.junctionTableName, rel.sourceFieldName, rel.targetFieldName] |
149 | | - ); |
150 | | - console.log(` ✓ ${rel.sourceTable} <-> ${rel.targetTable} (${rel.junctionTableName})`); |
151 | | - } catch (err: unknown) { |
152 | | - const msg = err instanceof Error ? err.message : String(err); |
153 | | - if (msg.includes('already exists')) { |
154 | | - console.log(` • ${rel.junctionTableName} (exists)`); |
155 | | - } else { |
156 | | - console.error(` ✗ ${rel.junctionTableName}: ${msg.slice(0, 200)}`); |
157 | | - } |
158 | | - } |
159 | | - } |
| 99 | +// --------------------------------------------------------------------------- |
| 100 | +// Build the relations array |
| 101 | +// --------------------------------------------------------------------------- |
160 | 102 |
|
161 | | - // -- BelongsTo FKs -- |
162 | | - for (const rel of BELONGS_TO_RELATIONS) { |
163 | | - try { |
164 | | - const sourceId = await resolveTableId(rel.sourceTable); |
165 | | - const targetId = await resolveTableId(rel.targetTable); |
166 | | - await pool.query( |
167 | | - `SELECT metaschema_modules_public.provision_relation( |
168 | | - database_id := $1::uuid, |
169 | | - relation_type := 'RelationBelongsTo', |
170 | | - source_table_id := $2::uuid, |
171 | | - target_table_id := $3::uuid, |
172 | | - field_name := $4, |
173 | | - delete_action := $5, |
174 | | - is_required := $6::boolean, |
175 | | - grant_roles := ARRAY[]::text[], |
176 | | - grants := '[]'::jsonb, |
177 | | - policies := '[]'::jsonb |
178 | | - )`, |
179 | | - [databaseId, sourceId, targetId, rel.fieldName, rel.deleteAction, rel.isRequired] |
180 | | - ); |
181 | | - console.log(` ✓ ${rel.sourceTable} -> ${rel.targetTable} (${rel.fieldName} FK)`); |
182 | | - } catch (err: unknown) { |
183 | | - const msg = err instanceof Error ? err.message : String(err); |
184 | | - if (msg.includes('already exists')) { |
185 | | - console.log(` • ${rel.sourceTable} -> ${rel.targetTable} FK (exists)`); |
186 | | - } else { |
187 | | - console.error(` ✗ ${rel.sourceTable} -> ${rel.targetTable} FK: ${msg.slice(0, 200)}`); |
188 | | - } |
189 | | - } |
190 | | - } |
| 103 | +const relations: BlueprintRelation[] = [ |
| 104 | + ...M2N.map((r): BlueprintRelation => ({ |
| 105 | + $type: 'RelationManyToMany', |
| 106 | + source_table: r.source_table, |
| 107 | + source_schema_name: SCHEMA, |
| 108 | + target_table: r.target_table, |
| 109 | + target_schema_name: SCHEMA, |
| 110 | + junction_table_name: r.junction_table_name, |
| 111 | + source_field_name: r.source_field_name, |
| 112 | + target_field_name: r.target_field_name, |
| 113 | + })), |
| 114 | + ...BELONGS_TO.map((r): BlueprintRelation => ({ |
| 115 | + $type: 'RelationBelongsTo', |
| 116 | + source_table: r.source_table, |
| 117 | + source_schema_name: SCHEMA, |
| 118 | + target_table: r.target_table, |
| 119 | + target_schema_name: SCHEMA, |
| 120 | + field_name: r.field_name, |
| 121 | + delete_action: r.delete_action, |
| 122 | + is_required: r.is_required, |
| 123 | + })), |
| 124 | +]; |
191 | 125 |
|
192 | | - // -- Disable RLS on all junction tables -- |
193 | | - // provision_relation enables RLS by default on M:N junctions. |
194 | | - // We explicitly disable it since we're running without security. |
195 | | - console.log(''); |
196 | | - for (const rel of M2N_RELATIONS) { |
197 | | - try { |
198 | | - const tableId = await resolveTableId(rel.junctionTableName); |
199 | | - // Get physical schema + table name |
200 | | - const { rows: tableRows } = await pool.query( |
201 | | - `SELECT s.schema_name, t.table_name |
202 | | - FROM metaschema_public."table" t |
203 | | - JOIN metaschema_public.schema s ON s.id = t.schema_id |
204 | | - WHERE t.id = $1`, |
205 | | - [tableId] |
206 | | - ); |
207 | | - if (tableRows[0]) { |
208 | | - const { schema_name, table_name } = tableRows[0]; |
209 | | - await pool.query(`ALTER TABLE "${schema_name}"."${table_name}" DISABLE ROW LEVEL SECURITY`); |
210 | | - // Also update the metaschema record |
211 | | - await pool.query( |
212 | | - `UPDATE metaschema_public."table" SET use_rls = false WHERE id = $1`, |
213 | | - [tableId] |
214 | | - ); |
215 | | - } |
216 | | - } catch { |
217 | | - // Junction table may not exist if creation was skipped |
218 | | - } |
219 | | - } |
220 | | - console.log(' ✓ RLS disabled on all junction tables'); |
| 126 | +const definition: BlueprintDefinition = { |
| 127 | + // No new tables — all referenced tables were created by upstream domain schemas. |
| 128 | + tables: [], |
| 129 | + relations, |
| 130 | +}; |
221 | 131 |
|
222 | | - console.log(`\n✅ Cross-relations complete!\n`); |
223 | | - } finally { |
224 | | - await pool.end(); |
225 | | - } |
| 132 | +async function main() { |
| 133 | + await provisionBlueprint(definition, 'Cross-Relations'); |
226 | 134 | } |
227 | 135 |
|
228 | 136 | export { main as default }; |
0 commit comments