Skip to content

Commit 42ebc49

Browse files
committed
refactor(provision): cross-relations via provisionBlueprint() (drop raw SQL)
1 parent c1b7086 commit 42ebc49

1 file changed

Lines changed: 101 additions & 193 deletions

File tree

Lines changed: 101 additions & 193 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
/**
2-
* cross-relations.ts — Cross-domain relations (declarative definition)
2+
* cross-relations.ts — Cross-domain relations (declarative blueprint)
33
*
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.
611
*
712
* M:N junctions:
813
* projects <-> contacts, tasks <-> contacts, tasks <-> projects, goals <-> habits,
@@ -12,217 +17,120 @@
1217
* events <-> notes, tasks <-> notes, contacts <-> memories,
1318
* companies <-> memories,
1419
* 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
1622
*
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
2127
*
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.
2531
*/
2632

27-
import { requireDatabaseId } from '../helpers';
33+
import {
34+
type BlueprintDefinition,
35+
type BlueprintRelation,
36+
provisionBlueprint,
37+
} from '../blueprint';
2838

29-
const databaseId = requireDatabaseId();
39+
const SCHEMA = 'app_public';
3040

3141
// ---------------------------------------------------------------------------
32-
// Declarative relation definitions
42+
// M:N junctions
3343
// ---------------------------------------------------------------------------
3444

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;
4951
}
5052

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' },
9075
];
9176

9277
// ---------------------------------------------------------------------------
93-
// Main
78+
// BelongsTo FKs (cross-schema)
9479
// ---------------------------------------------------------------------------
9580

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+
}
11688

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+
];
12898

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+
// ---------------------------------------------------------------------------
160102

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+
];
191125

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+
};
221131

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');
226134
}
227135

228136
export { main as default };

0 commit comments

Comments
 (0)