@open-mercato/search 0.4.2-canary-c02407ff85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/AGENTS.md +678 -0
  2. package/build.mjs +92 -0
  3. package/dist/di.js +157 -0
  4. package/dist/di.js.map +7 -0
  5. package/dist/fulltext/drivers/index.js +21 -0
  6. package/dist/fulltext/drivers/index.js.map +7 -0
  7. package/dist/fulltext/drivers/meilisearch/index.js +320 -0
  8. package/dist/fulltext/drivers/meilisearch/index.js.map +7 -0
  9. package/dist/fulltext/index.js +7 -0
  10. package/dist/fulltext/index.js.map +7 -0
  11. package/dist/fulltext/types.js +1 -0
  12. package/dist/fulltext/types.js.map +7 -0
  13. package/dist/index.js +12 -0
  14. package/dist/index.js.map +7 -0
  15. package/dist/indexer/index.js +8 -0
  16. package/dist/indexer/index.js.map +7 -0
  17. package/dist/indexer/search-indexer.js +848 -0
  18. package/dist/indexer/search-indexer.js.map +7 -0
  19. package/dist/indexer/subscribers/delete.js +41 -0
  20. package/dist/indexer/subscribers/delete.js.map +7 -0
  21. package/dist/lib/debug.js +34 -0
  22. package/dist/lib/debug.js.map +7 -0
  23. package/dist/lib/fallback-presenter.js +107 -0
  24. package/dist/lib/fallback-presenter.js.map +7 -0
  25. package/dist/lib/field-policy.js +75 -0
  26. package/dist/lib/field-policy.js.map +7 -0
  27. package/dist/lib/index.js +19 -0
  28. package/dist/lib/index.js.map +7 -0
  29. package/dist/lib/merger.js +93 -0
  30. package/dist/lib/merger.js.map +7 -0
  31. package/dist/lib/presenter-enricher.js +192 -0
  32. package/dist/lib/presenter-enricher.js.map +7 -0
  33. package/dist/modules/search/acl.js +14 -0
  34. package/dist/modules/search/acl.js.map +7 -0
  35. package/dist/modules/search/ai-tools.js +284 -0
  36. package/dist/modules/search/ai-tools.js.map +7 -0
  37. package/dist/modules/search/api/embeddings/reindex/cancel/route.js +65 -0
  38. package/dist/modules/search/api/embeddings/reindex/cancel/route.js.map +7 -0
  39. package/dist/modules/search/api/embeddings/reindex/route.js +165 -0
  40. package/dist/modules/search/api/embeddings/reindex/route.js.map +7 -0
  41. package/dist/modules/search/api/embeddings/route.js +246 -0
  42. package/dist/modules/search/api/embeddings/route.js.map +7 -0
  43. package/dist/modules/search/api/index/route.js +245 -0
  44. package/dist/modules/search/api/index/route.js.map +7 -0
  45. package/dist/modules/search/api/reindex/cancel/route.js +65 -0
  46. package/dist/modules/search/api/reindex/cancel/route.js.map +7 -0
  47. package/dist/modules/search/api/reindex/route.js +332 -0
  48. package/dist/modules/search/api/reindex/route.js.map +7 -0
  49. package/dist/modules/search/api/search/global/route.js +100 -0
  50. package/dist/modules/search/api/search/global/route.js.map +7 -0
  51. package/dist/modules/search/api/search/route.js +101 -0
  52. package/dist/modules/search/api/search/route.js.map +7 -0
  53. package/dist/modules/search/api/settings/fulltext/route.js +55 -0
  54. package/dist/modules/search/api/settings/fulltext/route.js.map +7 -0
  55. package/dist/modules/search/api/settings/global-search/route.js +80 -0
  56. package/dist/modules/search/api/settings/global-search/route.js.map +7 -0
  57. package/dist/modules/search/api/settings/route.js +118 -0
  58. package/dist/modules/search/api/settings/route.js.map +7 -0
  59. package/dist/modules/search/api/settings/vector-store/route.js +77 -0
  60. package/dist/modules/search/api/settings/vector-store/route.js.map +7 -0
  61. package/dist/modules/search/backend/config/search/page.js +10 -0
  62. package/dist/modules/search/backend/config/search/page.js.map +7 -0
  63. package/dist/modules/search/backend/config/search/page.meta.js +24 -0
  64. package/dist/modules/search/backend/config/search/page.meta.js.map +7 -0
  65. package/dist/modules/search/cli.js +698 -0
  66. package/dist/modules/search/cli.js.map +7 -0
  67. package/dist/modules/search/di.js +32 -0
  68. package/dist/modules/search/di.js.map +7 -0
  69. package/dist/modules/search/frontend/components/GlobalSearchDialog.js +357 -0
  70. package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +7 -0
  71. package/dist/modules/search/frontend/components/HybridSearchTable.js +343 -0
  72. package/dist/modules/search/frontend/components/HybridSearchTable.js.map +7 -0
  73. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js +303 -0
  74. package/dist/modules/search/frontend/components/SearchSettingsPageClient.js.map +7 -0
  75. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js +360 -0
  76. package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js.map +7 -0
  77. package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js +101 -0
  78. package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js.map +7 -0
  79. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +608 -0
  80. package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +7 -0
  81. package/dist/modules/search/frontend/index.js +9 -0
  82. package/dist/modules/search/frontend/index.js.map +7 -0
  83. package/dist/modules/search/frontend/utils.js +41 -0
  84. package/dist/modules/search/frontend/utils.js.map +7 -0
  85. package/dist/modules/search/i18n/de.json +61 -0
  86. package/dist/modules/search/i18n/en.json +72 -0
  87. package/dist/modules/search/i18n/es.json +61 -0
  88. package/dist/modules/search/i18n/pl.json +61 -0
  89. package/dist/modules/search/index.js +11 -0
  90. package/dist/modules/search/index.js.map +7 -0
  91. package/dist/modules/search/lib/auto-indexing.js +29 -0
  92. package/dist/modules/search/lib/auto-indexing.js.map +7 -0
  93. package/dist/modules/search/lib/embedding-config.js +131 -0
  94. package/dist/modules/search/lib/embedding-config.js.map +7 -0
  95. package/dist/modules/search/lib/global-search-config.js +45 -0
  96. package/dist/modules/search/lib/global-search-config.js.map +7 -0
  97. package/dist/modules/search/lib/reindex-lock.js +99 -0
  98. package/dist/modules/search/lib/reindex-lock.js.map +7 -0
  99. package/dist/modules/search/subscribers/fulltext_upsert.js +64 -0
  100. package/dist/modules/search/subscribers/fulltext_upsert.js.map +7 -0
  101. package/dist/modules/search/subscribers/vector_delete.js +58 -0
  102. package/dist/modules/search/subscribers/vector_delete.js.map +7 -0
  103. package/dist/modules/search/subscribers/vector_purge.js +142 -0
  104. package/dist/modules/search/subscribers/vector_purge.js.map +7 -0
  105. package/dist/modules/search/subscribers/vector_upsert.js +58 -0
  106. package/dist/modules/search/subscribers/vector_upsert.js.map +7 -0
  107. package/dist/modules/search/workers/fulltext-index.worker.js +240 -0
  108. package/dist/modules/search/workers/fulltext-index.worker.js.map +7 -0
  109. package/dist/modules/search/workers/vector-index.worker.js +234 -0
  110. package/dist/modules/search/workers/vector-index.worker.js.map +7 -0
  111. package/dist/queue/fulltext-indexing.js +15 -0
  112. package/dist/queue/fulltext-indexing.js.map +7 -0
  113. package/dist/queue/index.js +3 -0
  114. package/dist/queue/index.js.map +7 -0
  115. package/dist/queue/vector-indexing.js +15 -0
  116. package/dist/queue/vector-indexing.js.map +7 -0
  117. package/dist/service.js +286 -0
  118. package/dist/service.js.map +7 -0
  119. package/dist/strategies/fulltext.strategy.js +116 -0
  120. package/dist/strategies/fulltext.strategy.js.map +7 -0
  121. package/dist/strategies/index.js +12 -0
  122. package/dist/strategies/index.js.map +7 -0
  123. package/dist/strategies/token.strategy.js +80 -0
  124. package/dist/strategies/token.strategy.js.map +7 -0
  125. package/dist/strategies/vector.strategy.js +137 -0
  126. package/dist/strategies/vector.strategy.js.map +7 -0
  127. package/dist/types.js +1 -0
  128. package/dist/types.js.map +7 -0
  129. package/dist/vector/drivers/chromadb/index.js +44 -0
  130. package/dist/vector/drivers/chromadb/index.js.map +7 -0
  131. package/dist/vector/drivers/index.js +9 -0
  132. package/dist/vector/drivers/index.js.map +7 -0
  133. package/dist/vector/drivers/pgvector/index.js +509 -0
  134. package/dist/vector/drivers/pgvector/index.js.map +7 -0
  135. package/dist/vector/drivers/qdrant/index.js +44 -0
  136. package/dist/vector/drivers/qdrant/index.js.map +7 -0
  137. package/dist/vector/index.js +4 -0
  138. package/dist/vector/index.js.map +7 -0
  139. package/dist/vector/lib/vector-logs.js +33 -0
  140. package/dist/vector/lib/vector-logs.js.map +7 -0
  141. package/dist/vector/services/checksum.js +20 -0
  142. package/dist/vector/services/checksum.js.map +7 -0
  143. package/dist/vector/services/embedding.js +222 -0
  144. package/dist/vector/services/embedding.js.map +7 -0
  145. package/dist/vector/services/index.js +4 -0
  146. package/dist/vector/services/index.js.map +7 -0
  147. package/dist/vector/services/vector-index.service.js +960 -0
  148. package/dist/vector/services/vector-index.service.js.map +7 -0
  149. package/dist/vector/types/pg.d.js +1 -0
  150. package/dist/vector/types/pg.d.js.map +7 -0
  151. package/dist/vector/types.js +75 -0
  152. package/dist/vector/types.js.map +7 -0
  153. package/jest.config.cjs +19 -0
  154. package/package.json +142 -0
  155. package/src/__tests__/queue.test.ts +148 -0
  156. package/src/__tests__/service.test.ts +345 -0
  157. package/src/__tests__/workers.test.ts +319 -0
  158. package/src/di.ts +291 -0
  159. package/src/fulltext/drivers/index.ts +41 -0
  160. package/src/fulltext/drivers/meilisearch/index.ts +410 -0
  161. package/src/fulltext/index.ts +13 -0
  162. package/src/fulltext/types.ts +115 -0
  163. package/src/index.ts +36 -0
  164. package/src/indexer/index.ts +13 -0
  165. package/src/indexer/search-indexer.ts +1141 -0
  166. package/src/indexer/subscribers/delete.ts +49 -0
  167. package/src/lib/debug.ts +46 -0
  168. package/src/lib/fallback-presenter.ts +106 -0
  169. package/src/lib/field-policy.ts +169 -0
  170. package/src/lib/index.ts +13 -0
  171. package/src/lib/merger.ts +159 -0
  172. package/src/lib/presenter-enricher.ts +323 -0
  173. package/src/modules/search/README.md +694 -0
  174. package/src/modules/search/acl.ts +10 -0
  175. package/src/modules/search/ai-tools.ts +467 -0
  176. package/src/modules/search/api/embeddings/reindex/cancel/route.ts +77 -0
  177. package/src/modules/search/api/embeddings/reindex/route.ts +197 -0
  178. package/src/modules/search/api/embeddings/route.ts +304 -0
  179. package/src/modules/search/api/index/route.ts +297 -0
  180. package/src/modules/search/api/reindex/cancel/route.ts +77 -0
  181. package/src/modules/search/api/reindex/route.ts +419 -0
  182. package/src/modules/search/api/search/global/route.ts +120 -0
  183. package/src/modules/search/api/search/route.ts +121 -0
  184. package/src/modules/search/api/settings/fulltext/route.ts +82 -0
  185. package/src/modules/search/api/settings/global-search/route.ts +91 -0
  186. package/src/modules/search/api/settings/route.ts +187 -0
  187. package/src/modules/search/api/settings/vector-store/route.ts +105 -0
  188. package/src/modules/search/backend/config/search/page.meta.ts +22 -0
  189. package/src/modules/search/backend/config/search/page.tsx +12 -0
  190. package/src/modules/search/cli.ts +818 -0
  191. package/src/modules/search/di.ts +50 -0
  192. package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +436 -0
  193. package/src/modules/search/frontend/components/HybridSearchTable.tsx +418 -0
  194. package/src/modules/search/frontend/components/SearchSettingsPageClient.tsx +476 -0
  195. package/src/modules/search/frontend/components/sections/FulltextSearchSection.tsx +624 -0
  196. package/src/modules/search/frontend/components/sections/GlobalSearchSection.tsx +124 -0
  197. package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +943 -0
  198. package/src/modules/search/frontend/index.ts +3 -0
  199. package/src/modules/search/frontend/utils.ts +82 -0
  200. package/src/modules/search/i18n/de.json +61 -0
  201. package/src/modules/search/i18n/en.json +72 -0
  202. package/src/modules/search/i18n/es.json +61 -0
  203. package/src/modules/search/i18n/pl.json +61 -0
  204. package/src/modules/search/index.ts +9 -0
  205. package/src/modules/search/lib/auto-indexing.ts +35 -0
  206. package/src/modules/search/lib/embedding-config.ts +161 -0
  207. package/src/modules/search/lib/global-search-config.ts +69 -0
  208. package/src/modules/search/lib/reindex-lock.ts +201 -0
  209. package/src/modules/search/subscribers/fulltext_upsert.ts +83 -0
  210. package/src/modules/search/subscribers/vector_delete.ts +75 -0
  211. package/src/modules/search/subscribers/vector_purge.ts +161 -0
  212. package/src/modules/search/subscribers/vector_upsert.ts +75 -0
  213. package/src/modules/search/workers/fulltext-index.worker.ts +318 -0
  214. package/src/modules/search/workers/vector-index.worker.ts +292 -0
  215. package/src/queue/fulltext-indexing.ts +87 -0
  216. package/src/queue/index.ts +2 -0
  217. package/src/queue/vector-indexing.ts +66 -0
  218. package/src/service.ts +397 -0
  219. package/src/strategies/fulltext.strategy.ts +155 -0
  220. package/src/strategies/index.ts +17 -0
  221. package/src/strategies/token.strategy.ts +153 -0
  222. package/src/strategies/vector.strategy.ts +234 -0
  223. package/src/types.ts +38 -0
  224. package/src/vector/drivers/chromadb/index.ts +49 -0
  225. package/src/vector/drivers/index.ts +4 -0
  226. package/src/vector/drivers/pgvector/index.ts +627 -0
  227. package/src/vector/drivers/qdrant/index.ts +49 -0
  228. package/src/vector/index.ts +3 -0
  229. package/src/vector/lib/vector-logs.ts +46 -0
  230. package/src/vector/services/checksum.ts +18 -0
  231. package/src/vector/services/embedding.ts +275 -0
  232. package/src/vector/services/index.ts +3 -0
  233. package/src/vector/services/vector-index.service.ts +1234 -0
  234. package/src/vector/types/pg.d.ts +1 -0
  235. package/src/vector/types.ts +220 -0
  236. package/tsconfig.json +9 -0
  237. package/watch.mjs +6 -0
@@ -0,0 +1,627 @@
1
+ import { Pool } from 'pg'
2
+ import { searchDebugWarn } from '../../../lib/debug'
3
+
4
+ type PgPoolQueryResult<T> = { rows: T[]; rowCount?: number }
5
+ type PgPoolClient = {
6
+ query<T = any>(text: string, params?: any[]): Promise<PgPoolQueryResult<T>>
7
+ release(): void
8
+ }
9
+ type PgPool = {
10
+ connect(): Promise<PgPoolClient>
11
+ query<T = any>(text: string, params?: any[]): Promise<PgPoolQueryResult<T>>
12
+ end(): Promise<void>
13
+ }
14
+ import type {
15
+ VectorDriver,
16
+ VectorDriverDocument,
17
+ VectorDriverQuery,
18
+ VectorDriverQueryResult,
19
+ VectorDriverListParams,
20
+ VectorDriverCountParams,
21
+ VectorIndexEntry,
22
+ VectorDriverRemoveOrphansParams,
23
+ VectorResultPresenter,
24
+ VectorLinkDescriptor,
25
+ } from '../../types'
26
+
27
+ type PgVectorDriverOptions = {
28
+ pool?: PgPool
29
+ connectionString?: string
30
+ tableName?: string
31
+ migrationsTable?: string
32
+ dimension?: number
33
+ distanceMetric?: 'cosine' | 'euclidean' | 'inner'
34
+ }
35
+
36
+ const DEFAULT_TABLE = 'vector_search'
37
+ const DEFAULT_MIGRATIONS_TABLE = 'vector_search_migrations'
38
+ const DEFAULT_DIMENSION = 1536
39
+ const DRIVER_ID = 'pgvector' as const
40
+
41
+ function assertIdentifier(name: string, defaultName: string): string {
42
+ const candidate = name ?? defaultName
43
+ if (!candidate || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(candidate)) return defaultName
44
+ return candidate
45
+ }
46
+
47
+ function quoteIdent(name: string): string {
48
+ return `"${name}"`
49
+ }
50
+
51
+ function toVectorLiteral(values: number[]): string {
52
+ const formatted = values.map((n) => {
53
+ if (!Number.isFinite(n)) return '0'
54
+ const rounded = Math.fround(n)
55
+ return Number.isInteger(rounded) ? `${rounded}.0` : `${rounded}`
56
+ })
57
+ return `[${formatted.join(',')}]`
58
+ }
59
+
60
+ function parseJsonColumn<T>(value: unknown): T | null {
61
+ if (value === null || value === undefined) return null
62
+ if (typeof value === 'string') {
63
+ try {
64
+ return JSON.parse(value) as T
65
+ } catch {
66
+ // When `jsonb` stores a JSON string, node-postgres parses it into a plain JS string.
67
+ // In that case, there is nothing to JSON.parse — return the raw string value.
68
+ return value as unknown as T
69
+ }
70
+ }
71
+ if (typeof value === 'object') {
72
+ return value as T
73
+ }
74
+ return null
75
+ }
76
+
77
+ async function withClient<T>(pool: PgPool, fn: (client: PgPoolClient) => Promise<T>): Promise<T> {
78
+ const client = await pool.connect()
79
+ try {
80
+ return await fn(client)
81
+ } finally {
82
+ client.release()
83
+ }
84
+ }
85
+
86
+ export function createPgVectorDriver(opts: PgVectorDriverOptions = {}): VectorDriver {
87
+ const tableName = assertIdentifier(opts.tableName ?? DEFAULT_TABLE, DEFAULT_TABLE)
88
+ const migrationsTable = assertIdentifier(opts.migrationsTable ?? DEFAULT_MIGRATIONS_TABLE, DEFAULT_MIGRATIONS_TABLE)
89
+ let dimension = opts.dimension ?? DEFAULT_DIMENSION
90
+ const distanceMetric = opts.distanceMetric ?? 'cosine'
91
+ const tableIdent = quoteIdent(tableName)
92
+ const migrationsIdent = quoteIdent(migrationsTable)
93
+
94
+ const pool: PgPool =
95
+ opts.pool ??
96
+ (() => {
97
+ const conn = opts.connectionString ?? process.env.DATABASE_URL
98
+ if (!conn) {
99
+ throw new Error('[vector.pgvector] DATABASE_URL is not configured')
100
+ }
101
+ return new Pool({ connectionString: conn }) as unknown as PgPool
102
+ })()
103
+
104
+ let ready: Promise<void> | null = null
105
+
106
+ const ensureReady = async () => {
107
+ if (!ready) {
108
+ ready = withClient(pool, async (client) => {
109
+ const ensureExtension = async (extension: 'pgcrypto' | 'vector') => {
110
+ try {
111
+ await client.query(`CREATE EXTENSION IF NOT EXISTS ${extension}`)
112
+ } catch (error) {
113
+ const pgError = error as { code?: string; message?: string }
114
+ if (pgError?.code === '42501') {
115
+ const details = pgError.message ? ` (${pgError.message})` : ''
116
+ searchDebugWarn('vector.pgvector', `skipping ${extension} extension creation; requires superuser${details}`)
117
+ return
118
+ }
119
+ throw error
120
+ }
121
+ }
122
+
123
+ await ensureExtension('pgcrypto')
124
+ await ensureExtension('vector')
125
+
126
+ await client.query(
127
+ `CREATE TABLE IF NOT EXISTS ${migrationsIdent} (
128
+ id text primary key,
129
+ applied_at timestamptz not null default now()
130
+ )`,
131
+ )
132
+
133
+ await client.query(
134
+ `CREATE TABLE IF NOT EXISTS ${tableIdent} (
135
+ id uuid primary key default gen_random_uuid(),
136
+ driver_id text not null,
137
+ entity_id text not null,
138
+ record_id text not null,
139
+ tenant_id uuid not null,
140
+ organization_id uuid null,
141
+ checksum text not null,
142
+ embedding vector(${dimension}) not null,
143
+ url text null,
144
+ presenter jsonb null,
145
+ links jsonb null,
146
+ payload jsonb null,
147
+ result_title text null,
148
+ result_subtitle text null,
149
+ result_icon text null,
150
+ result_badge text null,
151
+ result_snapshot text null,
152
+ primary_link_href text null,
153
+ primary_link_label text null,
154
+ created_at timestamptz not null default now(),
155
+ updated_at timestamptz not null default now()
156
+ )`,
157
+ )
158
+
159
+ await client.query(
160
+ `CREATE UNIQUE INDEX IF NOT EXISTS ${tableName}_uniq ON ${tableIdent} (driver_id, entity_id, record_id, tenant_id)`,
161
+ )
162
+ await client.query(
163
+ `CREATE INDEX IF NOT EXISTS ${tableName}_lookup ON ${tableIdent} (tenant_id, organization_id, entity_id)`,
164
+ )
165
+ // ivfflat index only supports up to 2000 dimensions
166
+ // For higher dimensions, skip the index (uses sequential scan, slower but works)
167
+ // Also check actual table dimension in case driver was initialized with different value
168
+ let actualDimension = dimension
169
+ try {
170
+ const dimResult = await client.query<{ atttypmod: number }>(
171
+ `SELECT a.atttypmod
172
+ FROM pg_attribute a
173
+ JOIN pg_class c ON a.attrelid = c.oid
174
+ WHERE c.relname = $1
175
+ AND a.attname = 'embedding'
176
+ AND a.atttypmod > 0`,
177
+ [tableName]
178
+ )
179
+ if (dimResult.rows.length > 0 && dimResult.rows[0].atttypmod > 0) {
180
+ actualDimension = dimResult.rows[0].atttypmod
181
+ }
182
+ } catch {
183
+ // Ignore errors reading dimension, use configured value
184
+ }
185
+
186
+ if (actualDimension <= 2000) {
187
+ try {
188
+ await client.query(
189
+ `CREATE INDEX IF NOT EXISTS ${tableName}_embedding_idx ON ${tableIdent}
190
+ USING ivfflat (embedding vector_${distanceMetric}_ops) WITH (lists = 100)`,
191
+ )
192
+ } catch (indexErr: unknown) {
193
+ // Handle case where dimension exceeds ivfflat limit
194
+ const errorMessage = indexErr instanceof Error ? indexErr.message : String(indexErr)
195
+ if (errorMessage.includes('2000 dimensions')) {
196
+ searchDebugWarn('pgvector', 'Skipping ivfflat index - dimension exceeds 2000 limit. Searches will use sequential scan.')
197
+ } else {
198
+ throw indexErr
199
+ }
200
+ }
201
+ } else {
202
+ searchDebugWarn('pgvector', `Skipping ivfflat index - dimension ${actualDimension} exceeds 2000 limit. Searches will use sequential scan.`)
203
+ }
204
+
205
+ const columnAlters = [
206
+ `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_title text`,
207
+ `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_subtitle text`,
208
+ `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_icon text`,
209
+ `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_badge text`,
210
+ `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS result_snapshot text`,
211
+ `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS primary_link_href text`,
212
+ `ALTER TABLE ${tableIdent} ADD COLUMN IF NOT EXISTS primary_link_label text`,
213
+ ]
214
+ for (const statement of columnAlters) {
215
+ await client.query(statement)
216
+ }
217
+
218
+ await client.query(
219
+ `INSERT INTO ${migrationsIdent} (id, applied_at) VALUES ($1, now()) ON CONFLICT (id) DO NOTHING`,
220
+ ['0001_init'],
221
+ )
222
+ }).catch((err) => {
223
+ ready = null
224
+ throw err
225
+ })
226
+ }
227
+ return ready
228
+ }
229
+
230
+ const upsert = async (doc: VectorDriverDocument) => {
231
+ await ensureReady()
232
+ const vectorLiteral = toVectorLiteral(doc.embedding)
233
+ await pool.query(
234
+ `
235
+ INSERT INTO ${tableIdent} (
236
+ driver_id, entity_id, record_id, tenant_id, organization_id, checksum,
237
+ embedding, url, presenter, links, payload,
238
+ result_title, result_subtitle, result_icon, result_badge, result_snapshot,
239
+ primary_link_href, primary_link_label,
240
+ created_at, updated_at
241
+ )
242
+ VALUES (
243
+ $1, $2, $3, $4::uuid, $5::uuid, $6, $7::vector, $8, $9::jsonb, $10::jsonb, $11::jsonb,
244
+ $12, $13, $14, $15, $16, $17, $18,
245
+ now(), now()
246
+ )
247
+ ON CONFLICT (driver_id, entity_id, record_id, tenant_id)
248
+ DO UPDATE SET
249
+ organization_id = EXCLUDED.organization_id,
250
+ checksum = EXCLUDED.checksum,
251
+ embedding = EXCLUDED.embedding,
252
+ url = EXCLUDED.url,
253
+ presenter = EXCLUDED.presenter,
254
+ links = EXCLUDED.links,
255
+ payload = EXCLUDED.payload,
256
+ result_title = EXCLUDED.result_title,
257
+ result_subtitle = EXCLUDED.result_subtitle,
258
+ result_icon = EXCLUDED.result_icon,
259
+ result_badge = EXCLUDED.result_badge,
260
+ result_snapshot = EXCLUDED.result_snapshot,
261
+ primary_link_href = EXCLUDED.primary_link_href,
262
+ primary_link_label = EXCLUDED.primary_link_label,
263
+ updated_at = now()
264
+ `,
265
+ [
266
+ doc.driverId ?? DRIVER_ID,
267
+ doc.entityId,
268
+ doc.recordId,
269
+ doc.tenantId,
270
+ doc.organizationId ?? null,
271
+ doc.checksum,
272
+ vectorLiteral,
273
+ doc.url ?? null,
274
+ doc.presenter ? JSON.stringify(doc.presenter) : null,
275
+ doc.links ? JSON.stringify(doc.links) : null,
276
+ doc.payload ? JSON.stringify(doc.payload) : null,
277
+ doc.resultTitle,
278
+ doc.resultSubtitle ?? null,
279
+ doc.resultIcon ?? null,
280
+ doc.resultBadge ?? null,
281
+ doc.resultSnapshot ?? null,
282
+ doc.primaryLinkHref ?? null,
283
+ doc.primaryLinkLabel ?? null,
284
+ ],
285
+ )
286
+ }
287
+
288
+ const remove = async (entityId: string, recordId: string, tenantId: string) => {
289
+ await ensureReady()
290
+ await pool.query(
291
+ `DELETE FROM ${tableIdent} WHERE driver_id = $1 AND entity_id = $2 AND record_id = $3 AND tenant_id = $4::uuid`,
292
+ [DRIVER_ID, entityId, recordId, tenantId],
293
+ )
294
+ }
295
+
296
+ const getChecksum = async (entityId: string, recordId: string, tenantId: string): Promise<string | null> => {
297
+ await ensureReady()
298
+ const res = await pool.query<{ checksum: string }>(
299
+ `SELECT checksum FROM ${tableIdent} WHERE driver_id = $1 AND entity_id = $2 AND record_id = $3 AND tenant_id = $4::uuid`,
300
+ [DRIVER_ID, entityId, recordId, tenantId],
301
+ )
302
+ return res.rowCount ? res.rows[0].checksum : null
303
+ }
304
+
305
+ const purge = async (entityId: string, tenantId: string) => {
306
+ await ensureReady()
307
+ await pool.query(
308
+ `DELETE FROM ${tableIdent} WHERE driver_id = $1 AND entity_id = $2 AND tenant_id = $3::uuid`,
309
+ [DRIVER_ID, entityId, tenantId],
310
+ )
311
+ }
312
+
313
+ const query = async (input: VectorDriverQuery): Promise<VectorDriverQueryResult[]> => {
314
+ await ensureReady()
315
+ const vectorLiteral = toVectorLiteral(input.vector)
316
+ const filter = input.filter ?? { tenantId: '' }
317
+ // Check if organizationId is explicitly set in filter (vs undefined/missing)
318
+ // undefined = no org filter, null = filter for records with null org_id
319
+ const hasOrgFilter = 'organizationId' in filter
320
+ const params: any[] = [
321
+ vectorLiteral,
322
+ DRIVER_ID,
323
+ filter.tenantId,
324
+ hasOrgFilter ? (filter.organizationId ?? null) : null,
325
+ Array.isArray(filter.entityIds) && filter.entityIds.length ? filter.entityIds : null,
326
+ input.limit ?? 20,
327
+ hasOrgFilter, // $7: whether to apply org filter
328
+ ]
329
+ const res = await pool.query<{
330
+ entity_id: string
331
+ record_id: string
332
+ organization_id: string | null
333
+ checksum: string
334
+ url: string | null
335
+ presenter: string | null
336
+ links: string | null
337
+ payload: string | null
338
+ result_title: string | null
339
+ result_subtitle: string | null
340
+ result_icon: string | null
341
+ result_badge: string | null
342
+ result_snapshot: string | null
343
+ primary_link_href: string | null
344
+ primary_link_label: string | null
345
+ distance: number
346
+ }>(
347
+ `
348
+ SELECT
349
+ entity_id,
350
+ record_id,
351
+ organization_id,
352
+ checksum,
353
+ url,
354
+ presenter,
355
+ links,
356
+ payload,
357
+ result_title,
358
+ result_subtitle,
359
+ result_icon,
360
+ result_badge,
361
+ result_snapshot,
362
+ primary_link_href,
363
+ primary_link_label,
364
+ embedding <=> $1::vector AS distance
365
+ FROM ${tableIdent}
366
+ WHERE driver_id = $2
367
+ AND tenant_id = $3::uuid
368
+ AND (
369
+ $7::boolean = false
370
+ OR ($4::uuid IS NULL AND organization_id IS NULL)
371
+ OR ($4::uuid IS NOT NULL AND (organization_id = $4::uuid OR organization_id IS NULL))
372
+ )
373
+ AND (
374
+ $5::text[] IS NULL OR entity_id = ANY($5::text[])
375
+ )
376
+ ORDER BY embedding <=> $1::vector
377
+ LIMIT $6
378
+ `,
379
+ params,
380
+ )
381
+ return res.rows.map<VectorDriverQueryResult>((row) => {
382
+ const distance = typeof row.distance === 'number' ? row.distance : Number(row.distance || 1)
383
+ const score = 1 - distance
384
+ return {
385
+ entityId: row.entity_id,
386
+ recordId: row.record_id,
387
+ organizationId: row.organization_id ?? null,
388
+ checksum: row.checksum,
389
+ url: row.url ?? null,
390
+ presenter: parseJsonColumn<VectorResultPresenter>(row.presenter),
391
+ links: parseJsonColumn<VectorLinkDescriptor[]>(row.links),
392
+ payload: parseJsonColumn<Record<string, unknown>>(row.payload),
393
+ resultTitle: row.result_title ?? '',
394
+ resultSubtitle: row.result_subtitle ?? null,
395
+ resultIcon: row.result_icon ?? null,
396
+ resultBadge: row.result_badge ?? null,
397
+ resultSnapshot: row.result_snapshot ?? null,
398
+ primaryLinkHref: row.primary_link_href ?? null,
399
+ primaryLinkLabel: row.primary_link_label ?? null,
400
+ score,
401
+ }
402
+ })
403
+ }
404
+
405
+ const list = async (params: VectorDriverListParams): Promise<VectorIndexEntry[]> => {
406
+ await ensureReady()
407
+ const limit = Math.max(1, Math.min(params.limit ?? 50, 200))
408
+ const offset = Math.max(0, params.offset ?? 0)
409
+ const orderColumn = params.orderBy === 'created' ? 'created_at' : 'updated_at'
410
+ const conditions: string[] = [
411
+ 'driver_id = $1',
412
+ 'tenant_id = $2::uuid',
413
+ ]
414
+ const values: any[] = [DRIVER_ID, params.tenantId]
415
+ let nextParam = 3
416
+
417
+ if (params.organizationId === null) {
418
+ conditions.push('organization_id IS NULL')
419
+ } else if (typeof params.organizationId === 'string' && params.organizationId.length) {
420
+ conditions.push(`(organization_id = $${nextParam}::uuid OR organization_id IS NULL)`)
421
+ values.push(params.organizationId)
422
+ nextParam += 1
423
+ }
424
+
425
+ if (params.entityId) {
426
+ conditions.push(`entity_id = $${nextParam}::text`)
427
+ values.push(params.entityId)
428
+ nextParam += 1
429
+ }
430
+
431
+ const limitParam = nextParam
432
+ const offsetParam = nextParam + 1
433
+ values.push(limit, offset)
434
+
435
+ const sql = `
436
+ SELECT
437
+ entity_id,
438
+ record_id,
439
+ tenant_id,
440
+ organization_id,
441
+ checksum,
442
+ url,
443
+ presenter,
444
+ links,
445
+ payload,
446
+ result_title,
447
+ result_subtitle,
448
+ result_icon,
449
+ result_badge,
450
+ result_snapshot,
451
+ primary_link_href,
452
+ primary_link_label,
453
+ created_at,
454
+ updated_at
455
+ FROM ${tableIdent}
456
+ WHERE ${conditions.join('\n AND ')}
457
+ ORDER BY ${orderColumn} DESC
458
+ LIMIT $${limitParam} OFFSET $${offsetParam}
459
+ `
460
+
461
+ const res = await pool.query<{
462
+ entity_id: string
463
+ record_id: string
464
+ tenant_id: string
465
+ organization_id: string | null
466
+ checksum: string
467
+ url: string | null
468
+ presenter: string | null
469
+ links: string | null
470
+ payload: string | null
471
+ result_title: string | null
472
+ result_subtitle: string | null
473
+ result_icon: string | null
474
+ result_badge: string | null
475
+ result_snapshot: string | null
476
+ primary_link_href: string | null
477
+ primary_link_label: string | null
478
+ created_at: Date | string
479
+ updated_at: Date | string
480
+ }>(sql, values)
481
+ return res.rows.map<VectorIndexEntry>((row) => {
482
+ const presenter = parseJsonColumn<VectorResultPresenter>(row.presenter)
483
+ const links = parseJsonColumn<VectorLinkDescriptor[]>(row.links)
484
+ const payload = parseJsonColumn<Record<string, unknown>>(row.payload)
485
+ const createdAt =
486
+ row.created_at instanceof Date
487
+ ? row.created_at.toISOString()
488
+ : new Date(row.created_at ?? Date.now()).toISOString()
489
+ const updatedAt =
490
+ row.updated_at instanceof Date
491
+ ? row.updated_at.toISOString()
492
+ : new Date(row.updated_at ?? Date.now()).toISOString()
493
+ return {
494
+ entityId: row.entity_id,
495
+ recordId: row.record_id,
496
+ driverId: DRIVER_ID,
497
+ tenantId: row.tenant_id,
498
+ organizationId: row.organization_id ?? null,
499
+ checksum: row.checksum,
500
+ url: row.url ?? null,
501
+ presenter,
502
+ links,
503
+ payload,
504
+ metadata: payload,
505
+ resultTitle: row.result_title ?? '',
506
+ resultSubtitle: row.result_subtitle ?? null,
507
+ resultIcon: row.result_icon ?? null,
508
+ resultBadge: row.result_badge ?? null,
509
+ resultSnapshot: row.result_snapshot ?? null,
510
+ primaryLinkHref: row.primary_link_href ?? null,
511
+ primaryLinkLabel: row.primary_link_label ?? null,
512
+ createdAt,
513
+ updatedAt,
514
+ score: null,
515
+ }
516
+ })
517
+ }
518
+
519
+ const count = async (params: VectorDriverCountParams): Promise<number> => {
520
+ await ensureReady()
521
+ const conditions: string[] = [
522
+ 'driver_id = $1',
523
+ 'tenant_id = $2::uuid',
524
+ ]
525
+ const values: any[] = [DRIVER_ID, params.tenantId]
526
+ let nextParam = 3
527
+
528
+ if (params.organizationId === null) {
529
+ conditions.push('organization_id IS NULL')
530
+ } else if (typeof params.organizationId === 'string' && params.organizationId.length) {
531
+ conditions.push(`(organization_id = $${nextParam}::uuid OR organization_id IS NULL)`)
532
+ values.push(params.organizationId)
533
+ nextParam += 1
534
+ }
535
+ if (params.entityId) {
536
+ conditions.push(`entity_id = $${nextParam}::text`)
537
+ values.push(params.entityId)
538
+ nextParam += 1
539
+ }
540
+
541
+ const sql = `
542
+ SELECT count(*)::bigint AS total
543
+ FROM ${tableIdent}
544
+ WHERE ${conditions.join('\n AND ')}
545
+ `
546
+ const res = await pool.query<{ total: string }>(sql, values)
547
+ const raw = res.rows?.[0]?.total
548
+ if (!raw) return 0
549
+ const parsed = Number(raw)
550
+ return Number.isFinite(parsed) ? parsed : 0
551
+ }
552
+
553
+ const removeOrphans = async (params: VectorDriverRemoveOrphansParams): Promise<number> => {
554
+ await ensureReady()
555
+ const conditions: string[] = [
556
+ 'driver_id = $1',
557
+ 'entity_id = $2',
558
+ 'updated_at < $3::timestamptz',
559
+ ]
560
+ const values: any[] = [DRIVER_ID, params.entityId, (params.olderThan instanceof Date ? params.olderThan : new Date(params.olderThan)).toISOString()]
561
+ let nextParam = 4
562
+
563
+ if (params.tenantId !== undefined) {
564
+ conditions.push(`tenant_id is not distinct from $${nextParam}::uuid`)
565
+ values.push(params.tenantId)
566
+ nextParam += 1
567
+ }
568
+
569
+ if (params.organizationId !== undefined) {
570
+ conditions.push(`organization_id is not distinct from $${nextParam}::uuid`)
571
+ values.push(params.organizationId)
572
+ nextParam += 1
573
+ }
574
+
575
+ const sql = `
576
+ DELETE FROM ${tableIdent}
577
+ WHERE ${conditions.join('\n AND ')}
578
+ `
579
+ const res = await pool.query(sql, values)
580
+ return res.rowCount ?? 0
581
+ }
582
+
583
+ const getTableDimension = async (): Promise<number | null> => {
584
+ try {
585
+ const res = await pool.query<{ atttypmod: number }>(
586
+ `SELECT a.atttypmod
587
+ FROM pg_attribute a
588
+ JOIN pg_class c ON a.attrelid = c.oid
589
+ WHERE c.relname = $1
590
+ AND a.attname = 'embedding'
591
+ AND a.atttypmod > 0`,
592
+ [tableName]
593
+ )
594
+ if (res.rows.length > 0 && res.rows[0].atttypmod > 0) {
595
+ return res.rows[0].atttypmod
596
+ }
597
+ return null
598
+ } catch {
599
+ return null
600
+ }
601
+ }
602
+
603
+ const recreateWithDimension = async (newDimension: number): Promise<void> => {
604
+ await withClient(pool, async (client) => {
605
+ await client.query(`DROP TABLE IF EXISTS ${tableIdent} CASCADE`)
606
+ await client.query(`DROP TABLE IF EXISTS ${migrationsIdent} CASCADE`)
607
+ })
608
+ ready = null
609
+ dimension = newDimension
610
+ await ensureReady()
611
+ }
612
+
613
+ return {
614
+ id: 'pgvector',
615
+ ensureReady,
616
+ upsert,
617
+ delete: remove,
618
+ getChecksum,
619
+ purge,
620
+ query,
621
+ list,
622
+ count,
623
+ removeOrphans,
624
+ getTableDimension,
625
+ recreateWithDimension,
626
+ }
627
+ }
@@ -0,0 +1,49 @@
1
+ import type {
2
+ VectorDriver,
3
+ VectorDriverDocument,
4
+ VectorDriverQuery,
5
+ VectorDriverQueryResult,
6
+ VectorDriverCountParams,
7
+ } from '../../types'
8
+
9
+ function notImplemented(method: string): never {
10
+ throw new Error(`[vector.qdrant] ${method} not implemented yet`)
11
+ }
12
+
13
+ export function createQdrantDriver(): VectorDriver {
14
+ return {
15
+ id: 'qdrant',
16
+ async ensureReady() {
17
+ notImplemented('ensureReady')
18
+ },
19
+ async upsert(doc: VectorDriverDocument) {
20
+ void doc
21
+ notImplemented('upsert')
22
+ },
23
+ async delete(entityId: string, recordId: string, tenantId: string) {
24
+ void entityId
25
+ void recordId
26
+ void tenantId
27
+ notImplemented('delete')
28
+ },
29
+ async getChecksum(entityId: string, recordId: string, tenantId: string) {
30
+ void entityId
31
+ void recordId
32
+ void tenantId
33
+ notImplemented('getChecksum')
34
+ },
35
+ async query(input: VectorDriverQuery): Promise<VectorDriverQueryResult[]> {
36
+ void input
37
+ notImplemented('query')
38
+ },
39
+ async purge(entityId: string, tenantId: string) {
40
+ void entityId
41
+ void tenantId
42
+ notImplemented('purge')
43
+ },
44
+ async count(params: VectorDriverCountParams) {
45
+ void params
46
+ notImplemented('count')
47
+ },
48
+ }
49
+ }
@@ -0,0 +1,3 @@
1
+ export * from './types'
2
+ export * from './drivers'
3
+ export * from './services'