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
205146export { main as default } ;
0 commit comments