Skip to content

Commit 3e190e0

Browse files
committed
refactor(provision): use provisionBlueprint() for spatial relations (no raw SQL)
1 parent d8280f1 commit 3e190e0

1 file changed

Lines changed: 88 additions & 147 deletions

File tree

Lines changed: 88 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,59 @@
11
/**
22
* spatial-relations.ts — Cross-table PostGIS spatial relations
33
*
4-
* Provisions 5 @spatialRelation virtual relations across the agentic-db
5-
* schema. Each entry inserts a metaschema_public.spatial_relation row which
6-
* the sync_spatial_relation_tags trigger projects onto the owner column as
7-
* a @spatialRelation smart tag. The graphile-postgis PostgisSpatialRelationsPlugin
8-
* then exposes these as cross-table filters in the GraphQL `where:` input —
9-
* no FK required, no GeoJSON on the wire.
4+
* Declares 5 @spatialRelation virtual relations across the agentic-db schema
5+
* using the blueprint SDK (same declarative path as every other schema here).
106
*
11-
* Query shape (generated):
7+
* Each entry lands as a metaschema_public.spatial_relation row; the
8+
* sync_spatial_relation_tags trigger then projects a @spatialRelation smart
9+
* tag onto the owner column so graphile-postgis' PostgisSpatialRelationsPlugin
10+
* exposes it as a cross-table filter in the GraphQL `where:` input — no FK
11+
* required, no GeoJSON on the wire.
12+
*
13+
* Generated ORM shape:
1214
* orm.memory.findMany({
1315
* where: { nearbyPlaces: { some: { name: { equalTo: 'Bay Area' } } } },
1416
* });
1517
*
1618
* All 5 entries use `st_dwithin` because every agentic-db geom column is a
17-
* Point. Radii chosen to match the query intent:
19+
* Point. Radii (in metres since columns are geography) match the query intent:
1820
*
1921
* 1. memories → places @ 5 km "memories within 5 km of a known place"
2022
* 2. memories → contacts @ 2 km "memories logged near where a contact lives"
2123
* 3. trips → venues @ 1 km "trips whose destination is near a venue"
2224
* 4. events → venues @ 500 m "events near a specific venue (no FK)"
2325
* 5. memories → memories @ 1 km "what else happened near this memory" (self)
2426
*
25-
* Must run AFTER all domain schemas (source + target tables/columns must exist).
27+
* Must run AFTER all domain schemas — source and target tables/columns must
28+
* already exist on the database.
2629
*/
2730

28-
import { requireDatabaseId } from '../helpers';
31+
import {
32+
type BlueprintDefinition,
33+
type BlueprintRelation,
34+
provisionBlueprint,
35+
} from '../blueprint';
2936

30-
const databaseId = requireDatabaseId();
37+
const SCHEMA_NAME = 'app_public';
3138

32-
interface SpatialRelation {
33-
sourceTable: string;
34-
sourceField: string;
35-
targetTable: string;
36-
targetField: string;
39+
/**
40+
* Build a RelationSpatial blueprint entry.
41+
*
42+
* BlueprintRelation's public type admits `$type: 'RelationSpatial'` plus
43+
* `source_table`/`target_table` names; the generated `RelationSpatialParams`
44+
* shape uses `*_table_id`/`*_field_id` (UUIDs) for the server-side dispatcher.
45+
* In blueprint JSON we supply `source_field`/`target_field` as column *names* —
46+
* construct_blueprint resolves them via resolve_blueprint_field server-side.
47+
* A local interface keeps the blueprint-shape fields typed without a cast.
48+
*/
49+
interface SpatialRelationEntry {
50+
$type: 'RelationSpatial';
51+
source_table: string;
52+
source_schema_name: string;
53+
target_table: string;
54+
target_schema_name: string;
55+
source_field: string;
56+
target_field: string;
3757
name: string;
3858
operator:
3959
| 'st_contains'
@@ -44,162 +64,83 @@ interface SpatialRelation {
4464
| 'st_overlaps'
4565
| 'st_touches'
4666
| 'st_dwithin';
47-
paramName?: string;
48-
/** Human-readable radius for logging — metres when use_geography:true. */
49-
radiusLabel?: string;
67+
param_name?: string;
5068
}
5169

52-
const SPATIAL_RELATIONS: SpatialRelation[] = [
70+
const entries: SpatialRelationEntry[] = [
5371
{
54-
sourceTable: 'memories',
55-
sourceField: 'location_geo',
56-
targetTable: 'places',
57-
targetField: 'location_geo',
72+
$type: 'RelationSpatial',
73+
source_table: 'memories',
74+
source_schema_name: SCHEMA_NAME,
75+
target_table: 'places',
76+
target_schema_name: SCHEMA_NAME,
77+
source_field: 'location_geo',
78+
target_field: 'location_geo',
5879
name: 'nearbyPlaces',
5980
operator: 'st_dwithin',
60-
paramName: 'distance',
61-
radiusLabel: '5 km',
81+
param_name: 'distance',
6282
},
6383
{
64-
sourceTable: 'memories',
65-
sourceField: 'location_geo',
66-
targetTable: 'contacts',
67-
targetField: 'location_geo',
84+
$type: 'RelationSpatial',
85+
source_table: 'memories',
86+
source_schema_name: SCHEMA_NAME,
87+
target_table: 'contacts',
88+
target_schema_name: SCHEMA_NAME,
89+
source_field: 'location_geo',
90+
target_field: 'location_geo',
6891
name: 'nearbyContacts',
6992
operator: 'st_dwithin',
70-
paramName: 'distance',
71-
radiusLabel: '2 km',
93+
param_name: 'distance',
7294
},
7395
{
74-
sourceTable: 'trips',
75-
sourceField: 'destination_geo',
76-
targetTable: 'venues',
77-
targetField: 'location',
96+
$type: 'RelationSpatial',
97+
source_table: 'trips',
98+
source_schema_name: SCHEMA_NAME,
99+
target_table: 'venues',
100+
target_schema_name: SCHEMA_NAME,
101+
source_field: 'destination_geo',
102+
target_field: 'location',
78103
name: 'nearbyVenues',
79104
operator: 'st_dwithin',
80-
paramName: 'distance',
81-
radiusLabel: '1 km',
105+
param_name: 'distance',
82106
},
83107
{
84-
sourceTable: 'events',
85-
sourceField: 'location_geo',
86-
targetTable: 'venues',
87-
targetField: 'location',
108+
$type: 'RelationSpatial',
109+
source_table: 'events',
110+
source_schema_name: SCHEMA_NAME,
111+
target_table: 'venues',
112+
target_schema_name: SCHEMA_NAME,
113+
source_field: 'location_geo',
114+
target_field: 'location',
88115
name: 'nearbyVenues',
89116
operator: 'st_dwithin',
90-
paramName: 'distance',
91-
radiusLabel: '500 m',
117+
param_name: 'distance',
92118
},
93119
{
94-
sourceTable: 'memories',
95-
sourceField: 'location_geo',
96-
targetTable: 'memories',
97-
targetField: 'location_geo',
120+
$type: 'RelationSpatial',
121+
source_table: 'memories',
122+
source_schema_name: SCHEMA_NAME,
123+
target_table: 'memories',
124+
target_schema_name: SCHEMA_NAME,
125+
source_field: 'location_geo',
126+
target_field: 'location_geo',
98127
name: 'nearbyMemories',
99128
operator: 'st_dwithin',
100-
paramName: 'distance',
101-
radiusLabel: '1 km',
129+
param_name: 'distance',
102130
},
103131
];
104132

105-
async function main() {
106-
console.log('\n🗺️ Spatial Relations\n');
107-
console.log(` ${SPATIAL_RELATIONS.length} @spatialRelation virtual filters\n`);
108-
109-
const { Pool } = await import('pg');
110-
const pool = new Pool({ database: process.env.PGDATABASE || 'constructive' });
111-
112-
try {
113-
await pool.query("SET statement_timeout = '600s'");
133+
const definition: BlueprintDefinition = {
134+
// No new tables — we reference already-provisioned app_public tables by name.
135+
tables: [],
136+
// Cast-to-BlueprintRelation: the generated union type carries *_id props
137+
// (server-resolved UUIDs), but the blueprint JSON shape accepts field names
138+
// which construct_blueprint resolves via resolve_blueprint_field.
139+
relations: entries as unknown as BlueprintRelation[],
140+
};
114141

115-
// Resolve app_public schema id (avoids ambiguity with other 'memories'
116-
// style table names in other schemas, matching cross-relations.ts).
117-
const { rows: schemaRows } = await pool.query(
118-
`SELECT id FROM metaschema_public.schema
119-
WHERE database_id = $1 AND name = 'app_public' LIMIT 1`,
120-
[databaseId]
121-
);
122-
const appSchemaId = schemaRows[0]?.id;
123-
if (!appSchemaId) throw new Error('Could not resolve app_public schema id');
124-
125-
async function resolveTableId(name: string): Promise<string> {
126-
const { rows } = await pool.query(
127-
`SELECT id FROM metaschema_public."table"
128-
WHERE database_id = $1 AND schema_id = $2 AND name = $3 LIMIT 1`,
129-
[databaseId, appSchemaId, name]
130-
);
131-
if (!rows[0]?.id) throw new Error(`Table '${name}' not found in app_public`);
132-
return rows[0].id;
133-
}
134-
135-
async function resolveFieldId(
136-
tableId: string,
137-
fieldName: string
138-
): Promise<string> {
139-
const { rows } = await pool.query(
140-
`SELECT id FROM metaschema_public.field
141-
WHERE database_id = $1 AND table_id = $2 AND name = $3 LIMIT 1`,
142-
[databaseId, tableId, fieldName]
143-
);
144-
if (!rows[0]?.id) {
145-
throw new Error(`Field '${fieldName}' not found on table ${tableId}`);
146-
}
147-
return rows[0].id;
148-
}
149-
150-
for (const rel of SPATIAL_RELATIONS) {
151-
try {
152-
const sourceTableId = await resolveTableId(rel.sourceTable);
153-
const targetTableId = await resolveTableId(rel.targetTable);
154-
const sourceFieldId = await resolveFieldId(sourceTableId, rel.sourceField);
155-
const targetFieldId = await resolveFieldId(targetTableId, rel.targetField);
156-
157-
await pool.query(
158-
`SELECT metaschema_modules_public.provision_spatial_relation(
159-
p_database_id := $1::uuid,
160-
p_source_table_id := $2::uuid,
161-
p_source_field_id := $3::uuid,
162-
p_target_table_id := $4::uuid,
163-
p_target_field_id := $5::uuid,
164-
p_name := $6,
165-
p_operator := $7,
166-
p_param_name := $8
167-
)`,
168-
[
169-
databaseId,
170-
sourceTableId,
171-
sourceFieldId,
172-
targetTableId,
173-
targetFieldId,
174-
rel.name,
175-
rel.operator,
176-
rel.paramName ?? null,
177-
]
178-
);
179-
180-
const label = rel.radiusLabel ? ` @ ${rel.radiusLabel}` : '';
181-
console.log(
182-
` ✓ ${rel.sourceTable}.${rel.sourceField}${rel.targetTable}.${rel.targetField} ` +
183-
`(${rel.operator}${label}) as "${rel.name}"`
184-
);
185-
} catch (err: unknown) {
186-
const msg = err instanceof Error ? err.message : String(err);
187-
if (msg.includes('already exists') || msg.includes('duplicate key')) {
188-
console.log(
189-
` • ${rel.sourceTable}${rel.targetTable} "${rel.name}" (exists)`
190-
);
191-
} else {
192-
console.error(
193-
` ✗ ${rel.sourceTable}${rel.targetTable} "${rel.name}": ${msg.slice(0, 300)}`
194-
);
195-
}
196-
}
197-
}
198-
199-
console.log('\n✅ Spatial relations complete!\n');
200-
} finally {
201-
await pool.end();
202-
}
142+
async function main() {
143+
await provisionBlueprint(definition, 'Spatial Relations');
203144
}
204145

205146
export { main as default };

0 commit comments

Comments
 (0)