|
| 1 | +/** |
| 2 | + * Combined Unified Search + RelationSpatial test — ORM. |
| 3 | + * |
| 4 | + * Proves the exact query shape documented in the root README's |
| 5 | + * "Use the SDK (ORM)" section: a single `where:` clause that |
| 6 | + * composes the unified-search filter (`unifiedSearch: '...'`) with |
| 7 | + * a cross-table PostGIS spatial relation (`nearbyPlaces: { distance, |
| 8 | + * some: { ... } }`). |
| 9 | + * |
| 10 | + * Harness: `@constructive-io/graphql-test`'s `getConnections()` boots |
| 11 | + * the full `ConstructivePreset` plugin stack — which includes both |
| 12 | + * `UnifiedSearchPreset` (graphile-search) AND |
| 13 | + * `PostgisSpatialRelationsPlugin` — against a real deploy of the |
| 14 | + * agentic-db pgpm package. Same stack that `cnc server` serves in |
| 15 | + * production, so this test exercises the documented query shape |
| 16 | + * end-to-end through the generated SDK. |
| 17 | + * |
| 18 | + * Fixture data is seeded via raw SQL at known coordinates so the |
| 19 | + * distance half is deterministic and the text half has a clean |
| 20 | + * positive + two negatives. |
| 21 | + */ |
| 22 | +jest.setTimeout(300000); |
| 23 | +process.env.LOG_SCOPE = '@constructive-io/graphql-test'; |
| 24 | + |
| 25 | +import { getConnections, GraphQLTestAdapter } from '@constructive-io/graphql-test'; |
| 26 | +import type { GraphQLQueryFn } from '@constructive-io/graphql-test'; |
| 27 | +import { createClient } from '@agentic-db/sdk'; |
| 28 | +import { |
| 29 | + createAppJobsStub, |
| 30 | + grantAnonymousAccess, |
| 31 | +} from '../test-utils/helpers'; |
| 32 | + |
| 33 | +const SCHEMAS = ['agentic_db_app_public']; |
| 34 | + |
| 35 | +// Deterministic UUIDs so the assertions can name-check matches / negatives. |
| 36 | +const AGENT_ID = '00000000-0000-0000-0000-0000000000a1'; |
| 37 | +const MEMORY_SF = '00000000-0000-0000-0000-0000000000b1'; |
| 38 | +const MEMORY_OAKLAND = '00000000-0000-0000-0000-0000000000b2'; |
| 39 | +const MEMORY_NYC = '00000000-0000-0000-0000-0000000000b3'; |
| 40 | +const PLACE_FERRY = '00000000-0000-0000-0000-0000000000c1'; |
| 41 | +const PLACE_TOKYO = '00000000-0000-0000-0000-0000000000c2'; |
| 42 | + |
| 43 | +let db: any; |
| 44 | +let pg: any; |
| 45 | +let query: GraphQLQueryFn; |
| 46 | +let teardown: () => Promise<void>; |
| 47 | + |
| 48 | +beforeAll(async () => { |
| 49 | + const connections = await getConnections({ |
| 50 | + schemas: SCHEMAS, |
| 51 | + authRole: 'anonymous', |
| 52 | + }); |
| 53 | + ({ db, pg, query, teardown } = connections); |
| 54 | + |
| 55 | + await grantAnonymousAccess(pg); |
| 56 | + await createAppJobsStub(pg); |
| 57 | +}); |
| 58 | + |
| 59 | +afterAll(async () => { |
| 60 | + if (teardown) await teardown(); |
| 61 | +}); |
| 62 | + |
| 63 | +// Each test runs in its own transaction (begun here, rolled back |
| 64 | +// after the test). Seeding happens inside the transaction so the |
| 65 | +// ORM query in `it(...)` sees the inserted rows. |
| 66 | +beforeEach(() => db.beforeEach()); |
| 67 | +afterEach(() => db.afterEach()); |
| 68 | + |
| 69 | +describe('Unified search + RelationSpatial composition via ORM', () => { |
| 70 | + beforeEach(async () => { |
| 71 | + // Minimal agent row so memories have a valid agent_id FK. |
| 72 | + await pg.query( |
| 73 | + ` |
| 74 | + INSERT INTO agentic_db_app_public.agents (id, name) |
| 75 | + VALUES ($1, 'test-agent') |
| 76 | + ON CONFLICT (id) DO NOTHING |
| 77 | + `, |
| 78 | + [AGENT_ID] |
| 79 | + ); |
| 80 | + |
| 81 | + // Memories — raw SQL so location_geo can be set as a geography |
| 82 | + // Point in one statement. Title/content chosen so only MEMORY_SF |
| 83 | + // has meaningful text overlap with the query term |
| 84 | + // "Ferry Building coffee". |
| 85 | + await pg.query( |
| 86 | + ` |
| 87 | + INSERT INTO agentic_db_app_public.memories |
| 88 | + (id, agent_id, title, content, location_geo) |
| 89 | + VALUES |
| 90 | + ( |
| 91 | + $1, $4, |
| 92 | + 'Ferry Building keynote recap', |
| 93 | + 'Met a collaborator over coffee near the Ferry Building after the retrieval keynote.', |
| 94 | + ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography |
| 95 | + ), |
| 96 | + ( |
| 97 | + $2, $4, |
| 98 | + 'Oakland lunch', |
| 99 | + 'Reviewed an unrelated benchmark over lunch.', |
| 100 | + ST_SetSRID(ST_MakePoint(-122.2712, 37.8044), 4326)::geography |
| 101 | + ), |
| 102 | + ( |
| 103 | + $3, $4, |
| 104 | + 'NYC meetup', |
| 105 | + 'Caught up with the east-coast team; had pasta.', |
| 106 | + ST_SetSRID(ST_MakePoint(-74.0060, 40.7128), 4326)::geography |
| 107 | + ) |
| 108 | + ON CONFLICT (id) DO NOTHING |
| 109 | + `, |
| 110 | + [MEMORY_SF, MEMORY_OAKLAND, MEMORY_NYC, AGENT_ID] |
| 111 | + ); |
| 112 | + |
| 113 | + // Places — one matches the `category='market'` predicate and is |
| 114 | + // ~200 m from MEMORY_SF, ~13 km from MEMORY_OAKLAND, ~4100 km |
| 115 | + // from MEMORY_NYC. The Tokyo row exists to make sure the |
| 116 | + // spatial predicate actually filters (no memory is within 5 km |
| 117 | + // of Tokyo). |
| 118 | + await pg.query( |
| 119 | + ` |
| 120 | + INSERT INTO agentic_db_app_public.places |
| 121 | + (id, name, category, location_geo) |
| 122 | + VALUES |
| 123 | + ( |
| 124 | + $1, |
| 125 | + 'Ferry Building Marketplace', |
| 126 | + 'market', |
| 127 | + ST_SetSRID(ST_MakePoint(-122.3937, 37.7956), 4326)::geography |
| 128 | + ), |
| 129 | + ( |
| 130 | + $2, |
| 131 | + 'Tsukiji Outer Market', |
| 132 | + 'market', |
| 133 | + ST_SetSRID(ST_MakePoint(139.7700, 35.6655), 4326)::geography |
| 134 | + ) |
| 135 | + ON CONFLICT (id) DO NOTHING |
| 136 | + `, |
| 137 | + [PLACE_FERRY, PLACE_TOKYO] |
| 138 | + ); |
| 139 | + |
| 140 | + await db.publish(); |
| 141 | + }); |
| 142 | + |
| 143 | + it('memory.findMany(unifiedSearch + nearbyPlaces): composes text + spatial in one where', async () => { |
| 144 | + const sdk = createClient({ adapter: new GraphQLTestAdapter(query) }); |
| 145 | + |
| 146 | + const result = await sdk.memory |
| 147 | + .findMany({ |
| 148 | + where: { |
| 149 | + // Text half — matches on title/content via any of FTS, |
| 150 | + // BM25 or trgm depending on what's configured on the |
| 151 | + // underlying columns. MEMORY_SF contains both "Ferry |
| 152 | + // Building" and "coffee", so it scores strongly. |
| 153 | + unifiedSearch: 'Ferry Building coffee', |
| 154 | + // Spatial half — @spatialRelation smart tag on |
| 155 | + // memory.location_geo (declared in |
| 156 | + // packages/provision/src/schemas/spatial-relations.ts) |
| 157 | + // exposes `nearbyPlaces` on MemoryFilter. Body uses the |
| 158 | + // plugin's `{ distance, some: { …PlaceFilter… } }` shape. |
| 159 | + nearbyPlaces: { |
| 160 | + distance: 5000, |
| 161 | + some: { category: { equalTo: 'market' } }, |
| 162 | + }, |
| 163 | + }, |
| 164 | + first: 10, |
| 165 | + select: { |
| 166 | + id: true, |
| 167 | + title: true, |
| 168 | + searchScore: true, |
| 169 | + }, |
| 170 | + }) |
| 171 | + .execute(); |
| 172 | + |
| 173 | + if (!result.ok) { |
| 174 | + throw new Error( |
| 175 | + `combined unifiedSearch+nearbyPlaces query failed: ${JSON.stringify(result.errors, null, 2)}` |
| 176 | + ); |
| 177 | + } |
| 178 | + |
| 179 | + const nodes = result.data.memories.nodes; |
| 180 | + const ids = nodes.map((n: any) => n.id); |
| 181 | + |
| 182 | + // MEMORY_SF passes BOTH halves: its text matches the query and |
| 183 | + // it's within 5 km of the Ferry Building Marketplace (market). |
| 184 | + expect(ids).toContain(MEMORY_SF); |
| 185 | + |
| 186 | + // MEMORY_OAKLAND fails BOTH halves: text doesn't overlap, and |
| 187 | + // it's ~13 km from any market-category place (Ferry Building is |
| 188 | + // 13 km away; Tsukiji is across the Pacific). |
| 189 | + expect(ids).not.toContain(MEMORY_OAKLAND); |
| 190 | + |
| 191 | + // MEMORY_NYC fails BOTH halves: "pasta" doesn't overlap the |
| 192 | + // query, and NYC is ~4100 km from the nearest market. |
| 193 | + expect(ids).not.toContain(MEMORY_NYC); |
| 194 | + |
| 195 | + // The unified-search plugin populates `searchScore` as a |
| 196 | + // 0..1 blended relevance signal when any text algorithm fires. |
| 197 | + const sf = nodes.find((n: any) => n.id === MEMORY_SF); |
| 198 | + expect(sf).toBeDefined(); |
| 199 | + expect(typeof sf.searchScore).toBe('number'); |
| 200 | + expect(sf.searchScore).toBeGreaterThan(0); |
| 201 | + expect(sf.searchScore).toBeLessThanOrEqual(1); |
| 202 | + }); |
| 203 | +}); |
0 commit comments