@steno-ai/supabase-adapter 0.1.0
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.
- package/dist/client.d.ts +7 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +8 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/storage.d.ts +126 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +990 -0
- package/dist/storage.js.map +1 -0
- package/package.json +33 -0
- package/src/client.d.ts +7 -0
- package/src/client.d.ts.map +1 -0
- package/src/client.js +8 -0
- package/src/client.js.map +1 -0
- package/src/client.ts +13 -0
- package/src/index.d.ts +3 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +3 -0
- package/src/index.js.map +1 -0
- package/src/index.ts +2 -0
- package/src/migrations/001_extensions.sql +4 -0
- package/src/migrations/002_create_tenants.sql +14 -0
- package/src/migrations/003_create_api_keys.sql +15 -0
- package/src/migrations/004_create_sessions.sql +18 -0
- package/src/migrations/005_create_extractions.sql +34 -0
- package/src/migrations/006_create_facts.sql +68 -0
- package/src/migrations/007_create_entities.sql +26 -0
- package/src/migrations/008_create_fact_entities.sql +9 -0
- package/src/migrations/009_create_edges.sql +22 -0
- package/src/migrations/010_create_triggers.sql +18 -0
- package/src/migrations/011_create_memory_accesses.sql +20 -0
- package/src/migrations/012_create_usage_records.sql +16 -0
- package/src/migrations/013_create_functions.sql +19 -0
- package/src/migrations/014_create_rls_policies.sql +50 -0
- package/src/migrations/015_create_rpc_functions.sql +109 -0
- package/src/migrations/016_alter_source_ref.sql +2 -0
- package/src/migrations/017_keyword_search_rpc.sql +75 -0
- package/src/migrations/018_graph_traverse_rpc.sql +106 -0
- package/src/migrations/019_create_webhooks.sql +22 -0
- package/src/migrations/020_compound_search_rpc.sql +100 -0
- package/src/migrations/021_match_entities_rpc.sql +39 -0
- package/src/migrations/022_get_facts_for_entities_rpc.sql +123 -0
- package/src/migrations/023_add_event_date.sql +8 -0
- package/src/migrations/024_add_source_chunk.sql +3 -0
- package/src/migrations/025_update_edge_types_add_signing_key.sql +14 -0
- package/src/storage.d.ts +126 -0
- package/src/storage.d.ts.map +1 -0
- package/src/storage.js +990 -0
- package/src/storage.js.map +1 -0
- package/src/storage.ts +1180 -0
package/src/storage.ts
ADDED
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import type {
|
|
3
|
+
StorageAdapter,
|
|
4
|
+
PaginationOptions,
|
|
5
|
+
PaginatedResult,
|
|
6
|
+
VectorSearchOptions,
|
|
7
|
+
VectorSearchResult,
|
|
8
|
+
KeywordSearchOptions,
|
|
9
|
+
KeywordSearchResult,
|
|
10
|
+
CompoundSearchOptions,
|
|
11
|
+
CompoundSearchResult,
|
|
12
|
+
GraphTraversalOptions,
|
|
13
|
+
GraphTraversalResult,
|
|
14
|
+
} from '@steno-ai/engine';
|
|
15
|
+
import type {
|
|
16
|
+
Fact,
|
|
17
|
+
CreateFact,
|
|
18
|
+
Entity,
|
|
19
|
+
CreateEntity,
|
|
20
|
+
Edge,
|
|
21
|
+
CreateEdge,
|
|
22
|
+
Trigger,
|
|
23
|
+
CreateTrigger,
|
|
24
|
+
MemoryAccess,
|
|
25
|
+
CreateMemoryAccess,
|
|
26
|
+
Extraction,
|
|
27
|
+
CreateExtraction,
|
|
28
|
+
Session,
|
|
29
|
+
CreateSession,
|
|
30
|
+
Tenant,
|
|
31
|
+
CreateTenant,
|
|
32
|
+
ApiKey,
|
|
33
|
+
CreateApiKey,
|
|
34
|
+
UsageRecord,
|
|
35
|
+
Webhook,
|
|
36
|
+
CreateWebhook,
|
|
37
|
+
} from '@steno-ai/engine';
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// camelCase ↔ snake_case conversion utilities
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Convert a single camelCase key to snake_case.
|
|
45
|
+
* e.g. tenantId → tenant_id, validFrom → valid_from
|
|
46
|
+
*/
|
|
47
|
+
function camelToSnake(key: string): string {
|
|
48
|
+
return key.replace(/([A-Z])/g, (match) => `_${match.toLowerCase()}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Convert a single snake_case key to camelCase.
|
|
53
|
+
* e.g. tenant_id → tenantId, valid_from → validFrom
|
|
54
|
+
*/
|
|
55
|
+
function snakeToCamel(key: string): string {
|
|
56
|
+
return key.replace(/_([a-z])/g, (_, char) => char.toUpperCase());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Convert all top-level keys of a plain object from camelCase to snake_case.
|
|
61
|
+
* Nested objects (metadata, config, properties, condition) are preserved as-is.
|
|
62
|
+
* Arrays and null values are preserved.
|
|
63
|
+
*/
|
|
64
|
+
export function toSnakeCase(obj: Record<string, unknown>): Record<string, unknown> {
|
|
65
|
+
const result: Record<string, unknown> = {};
|
|
66
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
67
|
+
result[camelToSnake(key)] = value;
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Convert all top-level keys of a plain object from snake_case to camelCase.
|
|
74
|
+
* Nested objects (metadata, config, properties, condition) are preserved as-is.
|
|
75
|
+
* Arrays and null values are preserved.
|
|
76
|
+
*/
|
|
77
|
+
export function toCamelCase(obj: Record<string, unknown>): Record<string, unknown> {
|
|
78
|
+
const result: Record<string, unknown> = {};
|
|
79
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
80
|
+
result[snakeToCamel(key)] = value;
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// Error helpers
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
function throwSupabaseError(method: string, error: { message: string } | null): never {
|
|
90
|
+
throw new Error(`SupabaseStorageAdapter.${method}() failed: ${error?.message ?? 'unknown error'}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// =============================================================================
|
|
94
|
+
// SupabaseStorageAdapter
|
|
95
|
+
// =============================================================================
|
|
96
|
+
|
|
97
|
+
export class SupabaseStorageAdapter implements StorageAdapter {
|
|
98
|
+
constructor(private client: SupabaseClient) {}
|
|
99
|
+
|
|
100
|
+
async ping(): Promise<boolean> {
|
|
101
|
+
const { error } = await this.client.from('tenants').select('id').limit(1);
|
|
102
|
+
return !error;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Tenants
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
async createTenant(tenant: CreateTenant & { id: string }): Promise<Tenant> {
|
|
110
|
+
const row = toSnakeCase(tenant as unknown as Record<string, unknown>);
|
|
111
|
+
const { data, error } = await this.client
|
|
112
|
+
.from('tenants')
|
|
113
|
+
.insert(row)
|
|
114
|
+
.select()
|
|
115
|
+
.single();
|
|
116
|
+
if (error) throwSupabaseError('createTenant', error);
|
|
117
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Tenant;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async getTenant(id: string): Promise<Tenant | null> {
|
|
121
|
+
const { data, error } = await this.client
|
|
122
|
+
.from('tenants')
|
|
123
|
+
.select('*')
|
|
124
|
+
.eq('id', id)
|
|
125
|
+
.maybeSingle();
|
|
126
|
+
if (error) throwSupabaseError('getTenant', error);
|
|
127
|
+
if (!data) return null;
|
|
128
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Tenant;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async getTenantBySlug(slug: string): Promise<Tenant | null> {
|
|
132
|
+
const { data, error } = await this.client
|
|
133
|
+
.from('tenants')
|
|
134
|
+
.select('*')
|
|
135
|
+
.eq('slug', slug)
|
|
136
|
+
.maybeSingle();
|
|
137
|
+
if (error) throwSupabaseError('getTenantBySlug', error);
|
|
138
|
+
if (!data) return null;
|
|
139
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Tenant;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async updateTenant(id: string, updates: Partial<Tenant>): Promise<Tenant> {
|
|
143
|
+
const row = toSnakeCase(updates as unknown as Record<string, unknown>);
|
|
144
|
+
const { data, error } = await this.client
|
|
145
|
+
.from('tenants')
|
|
146
|
+
.update(row)
|
|
147
|
+
.eq('id', id)
|
|
148
|
+
.select()
|
|
149
|
+
.single();
|
|
150
|
+
if (error) throwSupabaseError('updateTenant', error);
|
|
151
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Tenant;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// API Keys
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
async createApiKey(
|
|
159
|
+
apiKey: CreateApiKey & { id: string; keyHash: string; keyPrefix: string },
|
|
160
|
+
): Promise<ApiKey> {
|
|
161
|
+
const row = toSnakeCase(apiKey as unknown as Record<string, unknown>);
|
|
162
|
+
const { data, error } = await this.client
|
|
163
|
+
.from('api_keys')
|
|
164
|
+
.insert(row)
|
|
165
|
+
.select()
|
|
166
|
+
.single();
|
|
167
|
+
if (error) throwSupabaseError('createApiKey', error);
|
|
168
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as ApiKey;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async getApiKeyByPrefix(prefix: string): Promise<ApiKey | null> {
|
|
172
|
+
const { data, error } = await this.client
|
|
173
|
+
.from('api_keys')
|
|
174
|
+
.select('*')
|
|
175
|
+
.eq('key_prefix', prefix)
|
|
176
|
+
.eq('active', true)
|
|
177
|
+
.maybeSingle();
|
|
178
|
+
if (error) throwSupabaseError('getApiKeyByPrefix', error);
|
|
179
|
+
if (!data) return null;
|
|
180
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as ApiKey;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async getApiKeysForTenant(tenantId: string): Promise<ApiKey[]> {
|
|
184
|
+
const { data, error } = await this.client
|
|
185
|
+
.from('api_keys')
|
|
186
|
+
.select('*')
|
|
187
|
+
.eq('tenant_id', tenantId);
|
|
188
|
+
if (error) throwSupabaseError('getApiKeysForTenant', error);
|
|
189
|
+
return (data ?? []).map(
|
|
190
|
+
(row) => toCamelCase(row as Record<string, unknown>) as unknown as ApiKey,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async revokeApiKey(tenantId: string, id: string): Promise<void> {
|
|
195
|
+
const { error } = await this.client
|
|
196
|
+
.from('api_keys')
|
|
197
|
+
.update({ active: false })
|
|
198
|
+
.eq('id', id)
|
|
199
|
+
.eq('tenant_id', tenantId);
|
|
200
|
+
if (error) throwSupabaseError('revokeApiKey', error);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async updateApiKeyLastUsed(id: string): Promise<void> {
|
|
204
|
+
const { error } = await this.client
|
|
205
|
+
.from('api_keys')
|
|
206
|
+
.update({ last_used_at: new Date().toISOString() })
|
|
207
|
+
.eq('id', id);
|
|
208
|
+
if (error) throwSupabaseError('updateApiKeyLastUsed', error);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Extractions
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
async createExtraction(extraction: CreateExtraction & { id: string }): Promise<Extraction> {
|
|
216
|
+
const row = toSnakeCase({
|
|
217
|
+
...(extraction as unknown as Record<string, unknown>),
|
|
218
|
+
status: 'queued',
|
|
219
|
+
});
|
|
220
|
+
const { data, error } = await this.client
|
|
221
|
+
.from('extractions')
|
|
222
|
+
.insert(row)
|
|
223
|
+
.select()
|
|
224
|
+
.single();
|
|
225
|
+
if (error) throwSupabaseError('createExtraction', error);
|
|
226
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Extraction;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async getExtraction(tenantId: string, id: string): Promise<Extraction | null> {
|
|
230
|
+
const { data, error } = await this.client
|
|
231
|
+
.from('extractions')
|
|
232
|
+
.select('*')
|
|
233
|
+
.eq('tenant_id', tenantId)
|
|
234
|
+
.eq('id', id)
|
|
235
|
+
.maybeSingle();
|
|
236
|
+
if (error) throwSupabaseError('getExtraction', error);
|
|
237
|
+
if (!data) return null;
|
|
238
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Extraction;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async updateExtraction(
|
|
242
|
+
tenantId: string,
|
|
243
|
+
id: string,
|
|
244
|
+
updates: Partial<Extraction>,
|
|
245
|
+
): Promise<Extraction> {
|
|
246
|
+
const row = toSnakeCase(updates as unknown as Record<string, unknown>);
|
|
247
|
+
const { data, error } = await this.client
|
|
248
|
+
.from('extractions')
|
|
249
|
+
.update(row)
|
|
250
|
+
.eq('tenant_id', tenantId)
|
|
251
|
+
.eq('id', id)
|
|
252
|
+
.select()
|
|
253
|
+
.single();
|
|
254
|
+
if (error) throwSupabaseError('updateExtraction', error);
|
|
255
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Extraction;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async getExtractionByHash(tenantId: string, inputHash: string): Promise<Extraction | null> {
|
|
259
|
+
const { data, error } = await this.client
|
|
260
|
+
.from('extractions')
|
|
261
|
+
.select('*')
|
|
262
|
+
.eq('tenant_id', tenantId)
|
|
263
|
+
.eq('input_hash', inputHash)
|
|
264
|
+
.maybeSingle();
|
|
265
|
+
if (error) throwSupabaseError('getExtractionByHash', error);
|
|
266
|
+
if (!data) return null;
|
|
267
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Extraction;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async getExtractionsByTenant(
|
|
271
|
+
tenantId: string,
|
|
272
|
+
options: PaginationOptions,
|
|
273
|
+
): Promise<PaginatedResult<Extraction>> {
|
|
274
|
+
const { limit, cursor } = options;
|
|
275
|
+
let query = this.client
|
|
276
|
+
.from('extractions')
|
|
277
|
+
.select('*')
|
|
278
|
+
.eq('tenant_id', tenantId)
|
|
279
|
+
.order('created_at', { ascending: false })
|
|
280
|
+
.limit(limit + 1);
|
|
281
|
+
|
|
282
|
+
if (cursor) {
|
|
283
|
+
query = query.lt('created_at', cursor);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const { data, error } = await query;
|
|
287
|
+
if (error) throwSupabaseError('getExtractionsByTenant', error);
|
|
288
|
+
|
|
289
|
+
const rows = data ?? [];
|
|
290
|
+
const hasMore = rows.length > limit;
|
|
291
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
292
|
+
const extractions = page.map(
|
|
293
|
+
(row) => toCamelCase(row as Record<string, unknown>) as unknown as Extraction,
|
|
294
|
+
);
|
|
295
|
+
const nextCursor = hasMore && page.length > 0
|
|
296
|
+
? (page[page.length - 1] as Record<string, unknown>)['created_at'] as string
|
|
297
|
+
: null;
|
|
298
|
+
|
|
299
|
+
return { data: extractions, cursor: nextCursor, hasMore };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Facts
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
async createFact(
|
|
307
|
+
fact: CreateFact & {
|
|
308
|
+
id: string;
|
|
309
|
+
lineageId: string;
|
|
310
|
+
embeddingModel: string;
|
|
311
|
+
embeddingDim: number;
|
|
312
|
+
embedding?: number[];
|
|
313
|
+
},
|
|
314
|
+
): Promise<Fact> {
|
|
315
|
+
const { embedding, ...rest } = fact;
|
|
316
|
+
const row = toSnakeCase(rest as unknown as Record<string, unknown>);
|
|
317
|
+
|
|
318
|
+
// Version defaults to 1 for new facts
|
|
319
|
+
if (!('version' in row)) {
|
|
320
|
+
row['version'] = 1;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Lineage ID is required; the interface guarantees it, but set it explicitly
|
|
324
|
+
if (!row['lineage_id']) {
|
|
325
|
+
row['lineage_id'] = fact.id; // fallback: use fact id as its own lineage
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Embeddings need special handling — pgvector expects a string like '[0.1,0.2,...]'
|
|
329
|
+
if (embedding !== undefined) {
|
|
330
|
+
row['embedding'] = `[${embedding.join(',')}]`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const { data, error } = await this.client
|
|
334
|
+
.from('facts')
|
|
335
|
+
.insert(row)
|
|
336
|
+
.select()
|
|
337
|
+
.single();
|
|
338
|
+
if (error) throwSupabaseError('createFact', error);
|
|
339
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Fact;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async getFact(tenantId: string, id: string): Promise<Fact | null> {
|
|
343
|
+
const { data, error } = await this.client
|
|
344
|
+
.from('facts')
|
|
345
|
+
.select('*')
|
|
346
|
+
.eq('tenant_id', tenantId)
|
|
347
|
+
.eq('id', id)
|
|
348
|
+
.maybeSingle();
|
|
349
|
+
if (error) throwSupabaseError('getFact', error);
|
|
350
|
+
if (!data) return null;
|
|
351
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Fact;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async getFactsByIds(tenantId: string, ids: string[]): Promise<Fact[]> {
|
|
355
|
+
if (ids.length === 0) return [];
|
|
356
|
+
const { data, error } = await this.client
|
|
357
|
+
.from('facts')
|
|
358
|
+
.select('*')
|
|
359
|
+
.eq('tenant_id', tenantId)
|
|
360
|
+
.in('id', ids);
|
|
361
|
+
if (error) throwSupabaseError('getFactsByIds', error);
|
|
362
|
+
return (data ?? []).map(
|
|
363
|
+
(row) => toCamelCase(row as Record<string, unknown>) as unknown as Fact,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async getFactsByLineage(tenantId: string, lineageId: string): Promise<Fact[]> {
|
|
368
|
+
const { data, error } = await this.client
|
|
369
|
+
.from('facts')
|
|
370
|
+
.select('*')
|
|
371
|
+
.eq('tenant_id', tenantId)
|
|
372
|
+
.eq('lineage_id', lineageId)
|
|
373
|
+
.order('version', { ascending: true });
|
|
374
|
+
if (error) throwSupabaseError('getFactsByLineage', error);
|
|
375
|
+
return (data ?? []).map(
|
|
376
|
+
(row) => toCamelCase(row as Record<string, unknown>) as unknown as Fact,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async invalidateFact(tenantId: string, id: string): Promise<void> {
|
|
381
|
+
const { error } = await this.client
|
|
382
|
+
.from('facts')
|
|
383
|
+
.update({ valid_until: new Date().toISOString() })
|
|
384
|
+
.eq('tenant_id', tenantId)
|
|
385
|
+
.eq('id', id);
|
|
386
|
+
if (error) throwSupabaseError('invalidateFact', error);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// Entities
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
async createEntity(
|
|
394
|
+
entity: CreateEntity & {
|
|
395
|
+
id: string;
|
|
396
|
+
embedding?: number[];
|
|
397
|
+
embeddingModel?: string;
|
|
398
|
+
embeddingDim?: number;
|
|
399
|
+
},
|
|
400
|
+
): Promise<Entity> {
|
|
401
|
+
const { embedding, ...rest } = entity;
|
|
402
|
+
const row = toSnakeCase(rest as unknown as Record<string, unknown>);
|
|
403
|
+
|
|
404
|
+
if (embedding !== undefined) {
|
|
405
|
+
row['embedding'] = `[${embedding.join(',')}]`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const { data, error } = await this.client
|
|
409
|
+
.from('entities')
|
|
410
|
+
.insert(row)
|
|
411
|
+
.select()
|
|
412
|
+
.single();
|
|
413
|
+
if (error) throwSupabaseError('createEntity', error);
|
|
414
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Entity;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async getEntity(tenantId: string, id: string): Promise<Entity | null> {
|
|
418
|
+
const { data, error } = await this.client
|
|
419
|
+
.from('entities')
|
|
420
|
+
.select('*')
|
|
421
|
+
.eq('tenant_id', tenantId)
|
|
422
|
+
.eq('id', id)
|
|
423
|
+
.maybeSingle();
|
|
424
|
+
if (error) throwSupabaseError('getEntity', error);
|
|
425
|
+
if (!data) return null;
|
|
426
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Entity;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async findEntityByCanonicalName(
|
|
430
|
+
tenantId: string,
|
|
431
|
+
canonicalName: string,
|
|
432
|
+
entityType: string,
|
|
433
|
+
): Promise<Entity | null> {
|
|
434
|
+
const { data, error } = await this.client
|
|
435
|
+
.from('entities')
|
|
436
|
+
.select('*')
|
|
437
|
+
.eq('tenant_id', tenantId)
|
|
438
|
+
.eq('canonical_name', canonicalName)
|
|
439
|
+
.eq('entity_type', entityType)
|
|
440
|
+
.maybeSingle();
|
|
441
|
+
if (error) throwSupabaseError('findEntityByCanonicalName', error);
|
|
442
|
+
if (!data) return null;
|
|
443
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Entity;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async findEntitiesByEmbedding(
|
|
447
|
+
tenantId: string,
|
|
448
|
+
embedding: number[],
|
|
449
|
+
limit: number,
|
|
450
|
+
minSimilarity: number = 0.3,
|
|
451
|
+
): Promise<Array<{ entity: Entity; similarity: number }>> {
|
|
452
|
+
const { data, error } = await this.client.rpc('match_entities', {
|
|
453
|
+
query_embedding: JSON.stringify(embedding),
|
|
454
|
+
match_tenant_id: tenantId,
|
|
455
|
+
match_count: limit,
|
|
456
|
+
min_similarity: minSimilarity,
|
|
457
|
+
});
|
|
458
|
+
if (error) throwSupabaseError('findEntitiesByEmbedding', error);
|
|
459
|
+
return (data ?? []).map((row: Record<string, unknown>) => ({
|
|
460
|
+
entity: toCamelCase(row) as unknown as Entity,
|
|
461
|
+
similarity: row['similarity'] as number,
|
|
462
|
+
}));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// Fact-Entity junction
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
async linkFactEntity(factId: string, entityId: string, role: string): Promise<void> {
|
|
470
|
+
const { error } = await this.client.from('fact_entities').insert({
|
|
471
|
+
fact_id: factId,
|
|
472
|
+
entity_id: entityId,
|
|
473
|
+
role,
|
|
474
|
+
});
|
|
475
|
+
if (error) throwSupabaseError('linkFactEntity', error);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// Edges
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
async createEdge(edge: CreateEdge & { id: string }): Promise<Edge> {
|
|
483
|
+
const row = toSnakeCase(edge as unknown as Record<string, unknown>);
|
|
484
|
+
const { data, error } = await this.client
|
|
485
|
+
.from('edges')
|
|
486
|
+
.insert(row)
|
|
487
|
+
.select()
|
|
488
|
+
.single();
|
|
489
|
+
if (error) throwSupabaseError('createEdge', error);
|
|
490
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Edge;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// Vector search
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
async vectorSearch(options: VectorSearchOptions): Promise<VectorSearchResult[]> {
|
|
498
|
+
const { embedding, tenantId, scope, scopeId, limit, minSimilarity } = options;
|
|
499
|
+
|
|
500
|
+
// Uses a Postgres function `match_facts` that must be created in migrations.
|
|
501
|
+
// See: packages/db/migrations/match_facts.sql
|
|
502
|
+
const { data, error } = await this.client.rpc('match_facts', {
|
|
503
|
+
query_embedding: `[${embedding.join(',')}]`,
|
|
504
|
+
match_tenant_id: tenantId,
|
|
505
|
+
match_scope: scope,
|
|
506
|
+
match_scope_id: scopeId,
|
|
507
|
+
match_count: limit,
|
|
508
|
+
min_similarity: minSimilarity ?? 0,
|
|
509
|
+
match_as_of: options.asOf?.toISOString() ?? null,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
if (error) throwSupabaseError('vectorSearch', error);
|
|
513
|
+
|
|
514
|
+
return (data ?? []).map((row: Record<string, unknown>) => ({
|
|
515
|
+
fact: toCamelCase(row as Record<string, unknown>) as unknown as Fact,
|
|
516
|
+
similarity: row['similarity'] as number,
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
// Usage
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
async incrementUsage(
|
|
525
|
+
tenantId: string,
|
|
526
|
+
tokens: number,
|
|
527
|
+
queries: number,
|
|
528
|
+
extractions: number,
|
|
529
|
+
costUsd: number,
|
|
530
|
+
): Promise<void> {
|
|
531
|
+
// Call the increment_usage Postgres RPC function which atomically
|
|
532
|
+
// increments existing totals via INSERT ... ON CONFLICT DO UPDATE.
|
|
533
|
+
// This avoids the bug where .upsert() replaces values instead of adding.
|
|
534
|
+
const { error } = await this.client.rpc('increment_usage', {
|
|
535
|
+
p_tenant_id: tenantId,
|
|
536
|
+
p_tokens: tokens,
|
|
537
|
+
p_queries: queries,
|
|
538
|
+
p_extractions: extractions,
|
|
539
|
+
p_cost_usd: costUsd,
|
|
540
|
+
});
|
|
541
|
+
if (error) throwSupabaseError('incrementUsage', error);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async getUsage(tenantId: string, periodStart: Date): Promise<UsageRecord | null> {
|
|
545
|
+
const { data, error } = await this.client
|
|
546
|
+
.from('usage_records')
|
|
547
|
+
.select('*')
|
|
548
|
+
.eq('tenant_id', tenantId)
|
|
549
|
+
.eq('period_start', periodStart.toISOString())
|
|
550
|
+
.maybeSingle();
|
|
551
|
+
if (error) throwSupabaseError('getUsage', error);
|
|
552
|
+
if (!data) return null;
|
|
553
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as UsageRecord;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async getCurrentUsage(tenantId: string): Promise<UsageRecord | null> {
|
|
557
|
+
const now = new Date();
|
|
558
|
+
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
559
|
+
return this.getUsage(tenantId, periodStart);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// Stubs — implemented in Plan 3 (Retrieval Engine)
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
async getFactsByScope(
|
|
567
|
+
tenantId: string,
|
|
568
|
+
scope: string,
|
|
569
|
+
scopeId: string,
|
|
570
|
+
options: PaginationOptions,
|
|
571
|
+
): Promise<PaginatedResult<Fact>> {
|
|
572
|
+
const { limit, cursor } = options;
|
|
573
|
+
let query = this.client
|
|
574
|
+
.from('facts')
|
|
575
|
+
.select('*')
|
|
576
|
+
.eq('tenant_id', tenantId)
|
|
577
|
+
.eq('scope', scope)
|
|
578
|
+
.eq('scope_id', scopeId)
|
|
579
|
+
.order('created_at', { ascending: false })
|
|
580
|
+
.limit(limit + 1);
|
|
581
|
+
|
|
582
|
+
if (cursor) {
|
|
583
|
+
query = query.lt('created_at', cursor);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const { data, error } = await query;
|
|
587
|
+
if (error) throwSupabaseError('getFactsByScope', error);
|
|
588
|
+
|
|
589
|
+
const rows = data ?? [];
|
|
590
|
+
const hasMore = rows.length > limit;
|
|
591
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
592
|
+
const facts = page.map(
|
|
593
|
+
(row) => toCamelCase(row as Record<string, unknown>) as unknown as Fact,
|
|
594
|
+
);
|
|
595
|
+
const nextCursor = hasMore && page.length > 0
|
|
596
|
+
? (page[page.length - 1] as Record<string, unknown>)['created_at'] as string
|
|
597
|
+
: null;
|
|
598
|
+
|
|
599
|
+
return { data: facts, cursor: nextCursor, hasMore };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async purgeFacts(tenantId: string, scope: string, scopeId: string): Promise<number> {
|
|
603
|
+
// First, get the IDs of facts to be purged so we can clean up related tables
|
|
604
|
+
const { data: factRows, error: fetchError } = await this.client
|
|
605
|
+
.from('facts')
|
|
606
|
+
.select('id')
|
|
607
|
+
.eq('tenant_id', tenantId)
|
|
608
|
+
.eq('scope', scope)
|
|
609
|
+
.eq('scope_id', scopeId);
|
|
610
|
+
if (fetchError) throwSupabaseError('purgeFacts', fetchError);
|
|
611
|
+
|
|
612
|
+
const factIds = (factRows ?? []).map((row) => (row as Record<string, unknown>)['id'] as string);
|
|
613
|
+
if (factIds.length === 0) return 0;
|
|
614
|
+
|
|
615
|
+
// Delete related fact_entities
|
|
616
|
+
const { error: feError } = await this.client
|
|
617
|
+
.from('fact_entities')
|
|
618
|
+
.delete()
|
|
619
|
+
.in('fact_id', factIds);
|
|
620
|
+
if (feError) throwSupabaseError('purgeFacts', feError);
|
|
621
|
+
|
|
622
|
+
// Delete related edges (where fact_id references one of these facts)
|
|
623
|
+
const { error: edgeError } = await this.client
|
|
624
|
+
.from('edges')
|
|
625
|
+
.delete()
|
|
626
|
+
.in('fact_id', factIds);
|
|
627
|
+
if (edgeError) throwSupabaseError('purgeFacts', edgeError);
|
|
628
|
+
|
|
629
|
+
// Delete the facts themselves
|
|
630
|
+
const { error: deleteError } = await this.client
|
|
631
|
+
.from('facts')
|
|
632
|
+
.delete()
|
|
633
|
+
.eq('tenant_id', tenantId)
|
|
634
|
+
.eq('scope', scope)
|
|
635
|
+
.eq('scope_id', scopeId);
|
|
636
|
+
if (deleteError) throwSupabaseError('purgeFacts', deleteError);
|
|
637
|
+
|
|
638
|
+
// Also delete extraction records for this scope so dedup cache doesn't serve stale results
|
|
639
|
+
const { error: extractionError } = await this.client
|
|
640
|
+
.from('extractions')
|
|
641
|
+
.delete()
|
|
642
|
+
.eq('tenant_id', tenantId)
|
|
643
|
+
.eq('scope', scope)
|
|
644
|
+
.eq('scope_id', scopeId);
|
|
645
|
+
if (extractionError) throwSupabaseError('purgeFacts', extractionError);
|
|
646
|
+
|
|
647
|
+
return factIds.length;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async updateDecayScores(
|
|
651
|
+
tenantId: string,
|
|
652
|
+
facts: Array<{ id: string; decayScore: number; lastAccessed?: Date; frequency?: number; importance?: number }>,
|
|
653
|
+
): Promise<void> {
|
|
654
|
+
// Batch update each fact's decay_score (and optionally last_accessed, frequency, importance)
|
|
655
|
+
for (const fact of facts) {
|
|
656
|
+
const updates: Record<string, unknown> = {
|
|
657
|
+
decay_score: fact.decayScore,
|
|
658
|
+
};
|
|
659
|
+
if (fact.lastAccessed !== undefined) {
|
|
660
|
+
updates['last_accessed'] = fact.lastAccessed.toISOString();
|
|
661
|
+
}
|
|
662
|
+
if (fact.frequency !== undefined) {
|
|
663
|
+
updates['frequency'] = fact.frequency;
|
|
664
|
+
}
|
|
665
|
+
if (fact.importance !== undefined) {
|
|
666
|
+
updates['importance'] = fact.importance;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const { error } = await this.client
|
|
670
|
+
.from('facts')
|
|
671
|
+
.update(updates)
|
|
672
|
+
.eq('tenant_id', tenantId)
|
|
673
|
+
.eq('id', fact.id);
|
|
674
|
+
if (error) throwSupabaseError('updateDecayScores', error);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async keywordSearch(options: KeywordSearchOptions): Promise<KeywordSearchResult[]> {
|
|
679
|
+
const { query, tenantId, scope, scopeId, limit, asOf } = options;
|
|
680
|
+
|
|
681
|
+
const { data, error } = await this.client.rpc('keyword_search_facts', {
|
|
682
|
+
search_query: query,
|
|
683
|
+
match_tenant_id: tenantId,
|
|
684
|
+
match_scope: scope,
|
|
685
|
+
match_scope_id: scopeId,
|
|
686
|
+
match_count: limit,
|
|
687
|
+
match_as_of: asOf?.toISOString() ?? null,
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
if (error) throwSupabaseError('keywordSearch', error);
|
|
691
|
+
|
|
692
|
+
return (data ?? []).map((row: Record<string, unknown>) => {
|
|
693
|
+
const rankScore = row['rank_score'] as number;
|
|
694
|
+
const converted = toCamelCase(row);
|
|
695
|
+
return {
|
|
696
|
+
fact: converted as unknown as Fact,
|
|
697
|
+
rankScore,
|
|
698
|
+
};
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async compoundSearch(options: CompoundSearchOptions): Promise<CompoundSearchResult[]> {
|
|
703
|
+
const { data, error } = await this.client.rpc('steno_search', {
|
|
704
|
+
query_embedding: `[${options.embedding.join(',')}]`,
|
|
705
|
+
search_query: options.query,
|
|
706
|
+
match_tenant_id: options.tenantId,
|
|
707
|
+
match_scope: options.scope,
|
|
708
|
+
match_scope_id: options.scopeId,
|
|
709
|
+
match_count: options.limit,
|
|
710
|
+
min_similarity: options.minSimilarity ?? 0,
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
if (error) throwSupabaseError('compoundSearch', error);
|
|
714
|
+
|
|
715
|
+
return (data ?? []).map((row: Record<string, unknown>) => ({
|
|
716
|
+
source: row['source'] as 'vector' | 'keyword',
|
|
717
|
+
fact: toCamelCase(row) as unknown as Fact,
|
|
718
|
+
relevanceScore: row['relevance_score'] as number,
|
|
719
|
+
}));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async getEntitiesForTenant(
|
|
723
|
+
tenantId: string,
|
|
724
|
+
options: PaginationOptions,
|
|
725
|
+
): Promise<PaginatedResult<Entity>> {
|
|
726
|
+
const { limit, cursor } = options;
|
|
727
|
+
let query = this.client
|
|
728
|
+
.from('entities')
|
|
729
|
+
.select('*')
|
|
730
|
+
.eq('tenant_id', tenantId)
|
|
731
|
+
.order('created_at', { ascending: true })
|
|
732
|
+
.limit(limit + 1); // fetch one extra to determine hasMore
|
|
733
|
+
|
|
734
|
+
if (cursor) {
|
|
735
|
+
query = query.gt('created_at', cursor);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const { data, error } = await query;
|
|
739
|
+
if (error) throwSupabaseError('getEntitiesForTenant', error);
|
|
740
|
+
|
|
741
|
+
const rows = data ?? [];
|
|
742
|
+
const hasMore = rows.length > limit;
|
|
743
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
744
|
+
const entities = page.map(
|
|
745
|
+
(row) => toCamelCase(row as Record<string, unknown>) as unknown as Entity,
|
|
746
|
+
);
|
|
747
|
+
const nextCursor = hasMore && page.length > 0
|
|
748
|
+
? (page[page.length - 1] as Record<string, unknown>)['created_at'] as string
|
|
749
|
+
: null;
|
|
750
|
+
|
|
751
|
+
return { data: entities, cursor: nextCursor, hasMore };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async getEntitiesForFact(factId: string): Promise<Entity[]> {
|
|
755
|
+
// First, get entity IDs from the junction table
|
|
756
|
+
const { data: junctionRows, error: junctionError } = await this.client
|
|
757
|
+
.from('fact_entities')
|
|
758
|
+
.select('entity_id')
|
|
759
|
+
.eq('fact_id', factId);
|
|
760
|
+
if (junctionError) throwSupabaseError('getEntitiesForFact', junctionError);
|
|
761
|
+
if (!junctionRows || junctionRows.length === 0) return [];
|
|
762
|
+
|
|
763
|
+
const entityIds = junctionRows.map((row: Record<string, unknown>) => row['entity_id'] as string);
|
|
764
|
+
|
|
765
|
+
// Then fetch the full entity records
|
|
766
|
+
const { data, error } = await this.client
|
|
767
|
+
.from('entities')
|
|
768
|
+
.select('*')
|
|
769
|
+
.in('id', entityIds);
|
|
770
|
+
if (error) throwSupabaseError('getEntitiesForFact', error);
|
|
771
|
+
return (data ?? []).map(
|
|
772
|
+
(row) => toCamelCase(row as Record<string, unknown>) as unknown as Entity,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async getFactsForEntity(
|
|
777
|
+
tenantId: string,
|
|
778
|
+
entityId: string,
|
|
779
|
+
options: PaginationOptions,
|
|
780
|
+
): Promise<PaginatedResult<Fact>> {
|
|
781
|
+
const { limit, cursor } = options;
|
|
782
|
+
|
|
783
|
+
// Use a join via PostgREST's !inner syntax to avoid URL-length issues
|
|
784
|
+
// with large IN clauses (408 UUIDs = ~15KB URL, exceeds PostgREST limit)
|
|
785
|
+
let query = this.client
|
|
786
|
+
.from('fact_entities')
|
|
787
|
+
.select('fact_id, facts!inner(*)')
|
|
788
|
+
.eq('entity_id', entityId)
|
|
789
|
+
.eq('facts.tenant_id', tenantId)
|
|
790
|
+
.order('created_at', { ascending: false, referencedTable: 'facts' })
|
|
791
|
+
.limit(limit + 1);
|
|
792
|
+
|
|
793
|
+
if (cursor) {
|
|
794
|
+
query = query.lt('facts.created_at', cursor);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const { data, error } = await query;
|
|
798
|
+
if (error) throwSupabaseError('getFactsForEntity', error);
|
|
799
|
+
|
|
800
|
+
const rows = data ?? [];
|
|
801
|
+
const hasMore = rows.length > limit;
|
|
802
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
803
|
+
const facts = page.map(
|
|
804
|
+
(row) => {
|
|
805
|
+
// Join returns { fact_id, facts: { ...fact_columns } }
|
|
806
|
+
const factRow = (row as Record<string, unknown>)['facts'] as Record<string, unknown>;
|
|
807
|
+
return toCamelCase(factRow) as unknown as Fact;
|
|
808
|
+
},
|
|
809
|
+
);
|
|
810
|
+
const nextCursor = hasMore && page.length > 0
|
|
811
|
+
? ((page[page.length - 1] as Record<string, unknown>)['facts'] as Record<string, unknown>)['created_at'] as string
|
|
812
|
+
: null;
|
|
813
|
+
|
|
814
|
+
return { data: facts, cursor: nextCursor, hasMore };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async getFactsForEntities(
|
|
818
|
+
tenantId: string,
|
|
819
|
+
entityIds: string[],
|
|
820
|
+
perEntityLimit: number,
|
|
821
|
+
): Promise<Array<{ entityId: string; fact: Fact }>> {
|
|
822
|
+
if (entityIds.length === 0) return [];
|
|
823
|
+
|
|
824
|
+
const { data, error } = await this.client.rpc('get_facts_for_entities', {
|
|
825
|
+
match_tenant_id: tenantId,
|
|
826
|
+
entity_ids: entityIds,
|
|
827
|
+
per_entity_limit: perEntityLimit,
|
|
828
|
+
});
|
|
829
|
+
if (error) throwSupabaseError('getFactsForEntities', error);
|
|
830
|
+
|
|
831
|
+
return (data ?? []).map((row: Record<string, unknown>) => {
|
|
832
|
+
const entityId = row['entity_id'] as string;
|
|
833
|
+
// Build fact from the row (all fact columns are returned)
|
|
834
|
+
const factRow = { ...row };
|
|
835
|
+
delete factRow['entity_id'];
|
|
836
|
+
factRow['id'] = factRow['fact_id'];
|
|
837
|
+
delete factRow['fact_id'];
|
|
838
|
+
return {
|
|
839
|
+
entityId,
|
|
840
|
+
fact: toCamelCase(factRow as Record<string, unknown>) as unknown as Fact,
|
|
841
|
+
};
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async getEdgesForEntity(tenantId: string, entityId: string): Promise<Edge[]> {
|
|
846
|
+
const { data, error } = await this.client
|
|
847
|
+
.from('edges')
|
|
848
|
+
.select('*')
|
|
849
|
+
.eq('tenant_id', tenantId)
|
|
850
|
+
.or(`source_id.eq.${entityId},target_id.eq.${entityId}`);
|
|
851
|
+
if (error) throwSupabaseError('getEdgesForEntity', error);
|
|
852
|
+
return (data ?? []).map(
|
|
853
|
+
(row) => toCamelCase(row as Record<string, unknown>) as unknown as Edge,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async graphTraversal(options: GraphTraversalOptions): Promise<GraphTraversalResult> {
|
|
858
|
+
const { data, error } = await this.client.rpc('graph_traverse', {
|
|
859
|
+
match_tenant_id: options.tenantId,
|
|
860
|
+
seed_entity_ids: options.entityIds,
|
|
861
|
+
max_depth: options.maxDepth,
|
|
862
|
+
max_entities: options.maxEntities,
|
|
863
|
+
match_as_of: options.asOf?.toISOString() ?? null,
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
if (error) throwSupabaseError('graphTraversal', error);
|
|
867
|
+
|
|
868
|
+
const rows = (data ?? []) as Record<string, unknown>[];
|
|
869
|
+
|
|
870
|
+
// Deduplicate entities by id
|
|
871
|
+
const entityMap = new Map<string, Entity>();
|
|
872
|
+
const edgeMap = new Map<string, Edge>();
|
|
873
|
+
|
|
874
|
+
for (const row of rows) {
|
|
875
|
+
const entityId = row['entity_id'] as string;
|
|
876
|
+
if (!entityMap.has(entityId)) {
|
|
877
|
+
entityMap.set(entityId, {
|
|
878
|
+
id: entityId,
|
|
879
|
+
tenantId: options.tenantId,
|
|
880
|
+
name: row['entity_name'] as string,
|
|
881
|
+
entityType: row['entity_type'] as string,
|
|
882
|
+
canonicalName: row['canonical_name'] as string,
|
|
883
|
+
properties: (row['properties'] as Record<string, unknown>) ?? {},
|
|
884
|
+
embeddingModel: null,
|
|
885
|
+
embeddingDim: null,
|
|
886
|
+
mergeTargetId: null,
|
|
887
|
+
createdAt: new Date(),
|
|
888
|
+
updatedAt: new Date(),
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Filter out null edges (seed entities at depth 0 have null edge fields)
|
|
893
|
+
const edgeId = row['edge_id'] as string | null;
|
|
894
|
+
if (edgeId && !edgeMap.has(edgeId)) {
|
|
895
|
+
edgeMap.set(edgeId, {
|
|
896
|
+
id: edgeId,
|
|
897
|
+
tenantId: options.tenantId,
|
|
898
|
+
sourceId: row['edge_source_id'] as string,
|
|
899
|
+
targetId: row['edge_target_id'] as string,
|
|
900
|
+
relation: row['edge_relation'] as string,
|
|
901
|
+
edgeType: row['edge_type'] as Edge['edgeType'],
|
|
902
|
+
weight: (row['edge_weight'] as number) ?? 1.0,
|
|
903
|
+
validFrom: row['edge_valid_from'] ? new Date(row['edge_valid_from'] as string) : new Date(),
|
|
904
|
+
validUntil: row['edge_valid_until'] ? new Date(row['edge_valid_until'] as string) : null,
|
|
905
|
+
factId: null,
|
|
906
|
+
confidence: (row['edge_confidence'] as number) ?? 1.0,
|
|
907
|
+
metadata: {},
|
|
908
|
+
createdAt: new Date(),
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
return {
|
|
914
|
+
entities: Array.from(entityMap.values()),
|
|
915
|
+
edges: Array.from(edgeMap.values()),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async createTrigger(trigger: CreateTrigger & { id: string }): Promise<Trigger> {
|
|
920
|
+
const row = toSnakeCase(trigger as unknown as Record<string, unknown>);
|
|
921
|
+
const { data, error } = await this.client
|
|
922
|
+
.from('triggers')
|
|
923
|
+
.insert(row)
|
|
924
|
+
.select()
|
|
925
|
+
.single();
|
|
926
|
+
if (error) throwSupabaseError('createTrigger', error);
|
|
927
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Trigger;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async getTrigger(tenantId: string, id: string): Promise<Trigger | null> {
|
|
931
|
+
const { data, error } = await this.client
|
|
932
|
+
.from('triggers')
|
|
933
|
+
.select('*')
|
|
934
|
+
.eq('tenant_id', tenantId)
|
|
935
|
+
.eq('id', id)
|
|
936
|
+
.maybeSingle();
|
|
937
|
+
if (error) throwSupabaseError('getTrigger', error);
|
|
938
|
+
if (!data) return null;
|
|
939
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Trigger;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async getActiveTriggers(tenantId: string, scope: string, scopeId: string): Promise<Trigger[]> {
|
|
943
|
+
const { data, error } = await this.client
|
|
944
|
+
.from('triggers')
|
|
945
|
+
.select('*')
|
|
946
|
+
.eq('tenant_id', tenantId)
|
|
947
|
+
.eq('scope', scope)
|
|
948
|
+
.eq('scope_id', scopeId)
|
|
949
|
+
.eq('active', true)
|
|
950
|
+
.order('priority', { ascending: false });
|
|
951
|
+
if (error) throwSupabaseError('getActiveTriggers', error);
|
|
952
|
+
return (data ?? []).map(
|
|
953
|
+
(row) => toCamelCase(row as Record<string, unknown>) as unknown as Trigger,
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async updateTrigger(
|
|
958
|
+
tenantId: string,
|
|
959
|
+
id: string,
|
|
960
|
+
updates: Partial<Trigger>,
|
|
961
|
+
): Promise<Trigger> {
|
|
962
|
+
const row = toSnakeCase(updates as unknown as Record<string, unknown>);
|
|
963
|
+
const { data, error } = await this.client
|
|
964
|
+
.from('triggers')
|
|
965
|
+
.update(row)
|
|
966
|
+
.eq('tenant_id', tenantId)
|
|
967
|
+
.eq('id', id)
|
|
968
|
+
.select()
|
|
969
|
+
.single();
|
|
970
|
+
if (error) throwSupabaseError('updateTrigger', error);
|
|
971
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Trigger;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async deleteTrigger(tenantId: string, id: string): Promise<void> {
|
|
975
|
+
const { error } = await this.client
|
|
976
|
+
.from('triggers')
|
|
977
|
+
.delete()
|
|
978
|
+
.eq('tenant_id', tenantId)
|
|
979
|
+
.eq('id', id);
|
|
980
|
+
if (error) throwSupabaseError('deleteTrigger', error);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async incrementTriggerFired(tenantId: string, id: string): Promise<void> {
|
|
984
|
+
const { error } = await this.client.rpc('increment_trigger_fired', {
|
|
985
|
+
p_tenant_id: tenantId,
|
|
986
|
+
p_trigger_id: id,
|
|
987
|
+
});
|
|
988
|
+
if (error) throwSupabaseError('incrementTriggerFired', error);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async createMemoryAccess(
|
|
992
|
+
access: CreateMemoryAccess & { id: string },
|
|
993
|
+
): Promise<MemoryAccess> {
|
|
994
|
+
const row = toSnakeCase(access as unknown as Record<string, unknown>);
|
|
995
|
+
const { data, error } = await this.client
|
|
996
|
+
.from('memory_accesses')
|
|
997
|
+
.insert(row)
|
|
998
|
+
.select()
|
|
999
|
+
.single();
|
|
1000
|
+
if (error) throwSupabaseError('createMemoryAccess', error);
|
|
1001
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as MemoryAccess;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async updateFeedback(
|
|
1005
|
+
tenantId: string,
|
|
1006
|
+
factId: string,
|
|
1007
|
+
feedback: { wasUseful: boolean; feedbackType: string; feedbackDetail?: string; wasCorrected?: boolean },
|
|
1008
|
+
): Promise<void> {
|
|
1009
|
+
// Update the MOST RECENT memory access for this fact
|
|
1010
|
+
const { data: accessRows, error: findError } = await this.client
|
|
1011
|
+
.from('memory_accesses')
|
|
1012
|
+
.select('id')
|
|
1013
|
+
.eq('tenant_id', tenantId)
|
|
1014
|
+
.eq('fact_id', factId)
|
|
1015
|
+
.order('accessed_at', { ascending: false })
|
|
1016
|
+
.limit(1);
|
|
1017
|
+
if (findError) throwSupabaseError('updateFeedback', findError);
|
|
1018
|
+
if (!accessRows || accessRows.length === 0) return;
|
|
1019
|
+
|
|
1020
|
+
const accessId = (accessRows[0] as Record<string, unknown>)['id'] as string;
|
|
1021
|
+
const { error } = await this.client
|
|
1022
|
+
.from('memory_accesses')
|
|
1023
|
+
.update({
|
|
1024
|
+
was_useful: feedback.wasUseful,
|
|
1025
|
+
feedback_type: feedback.feedbackType,
|
|
1026
|
+
feedback_detail: feedback.feedbackDetail ?? null,
|
|
1027
|
+
was_corrected: feedback.wasCorrected ?? false,
|
|
1028
|
+
})
|
|
1029
|
+
.eq('id', accessId);
|
|
1030
|
+
if (error) throwSupabaseError('updateFeedback', error);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async createSession(session: CreateSession & { id: string }): Promise<Session> {
|
|
1034
|
+
const row = toSnakeCase(session as unknown as Record<string, unknown>);
|
|
1035
|
+
const { data, error } = await this.client
|
|
1036
|
+
.from('sessions')
|
|
1037
|
+
.insert(row)
|
|
1038
|
+
.select()
|
|
1039
|
+
.single();
|
|
1040
|
+
if (error) throwSupabaseError('createSession', error);
|
|
1041
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Session;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
async getSession(tenantId: string, id: string): Promise<Session | null> {
|
|
1045
|
+
const { data, error } = await this.client
|
|
1046
|
+
.from('sessions')
|
|
1047
|
+
.select('*')
|
|
1048
|
+
.eq('tenant_id', tenantId)
|
|
1049
|
+
.eq('id', id)
|
|
1050
|
+
.maybeSingle();
|
|
1051
|
+
if (error) throwSupabaseError('getSession', error);
|
|
1052
|
+
if (!data) return null;
|
|
1053
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Session;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async endSession(
|
|
1057
|
+
tenantId: string,
|
|
1058
|
+
id: string,
|
|
1059
|
+
summary?: string,
|
|
1060
|
+
topics?: string[],
|
|
1061
|
+
): Promise<Session> {
|
|
1062
|
+
const updates: Record<string, unknown> = {
|
|
1063
|
+
ended_at: new Date().toISOString(),
|
|
1064
|
+
};
|
|
1065
|
+
if (summary !== undefined) updates['summary'] = summary;
|
|
1066
|
+
if (topics !== undefined) updates['topics'] = topics;
|
|
1067
|
+
|
|
1068
|
+
const { data, error } = await this.client
|
|
1069
|
+
.from('sessions')
|
|
1070
|
+
.update(updates)
|
|
1071
|
+
.eq('tenant_id', tenantId)
|
|
1072
|
+
.eq('id', id)
|
|
1073
|
+
.select()
|
|
1074
|
+
.single();
|
|
1075
|
+
if (error) throwSupabaseError('endSession', error);
|
|
1076
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Session;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
async getSessionsByScope(
|
|
1080
|
+
tenantId: string,
|
|
1081
|
+
scope: string,
|
|
1082
|
+
scopeId: string,
|
|
1083
|
+
options: PaginationOptions,
|
|
1084
|
+
): Promise<PaginatedResult<Session>> {
|
|
1085
|
+
const { limit, cursor } = options;
|
|
1086
|
+
let query = this.client
|
|
1087
|
+
.from('sessions')
|
|
1088
|
+
.select('*')
|
|
1089
|
+
.eq('tenant_id', tenantId)
|
|
1090
|
+
.eq('scope', scope)
|
|
1091
|
+
.eq('scope_id', scopeId)
|
|
1092
|
+
.order('started_at', { ascending: false })
|
|
1093
|
+
.limit(limit + 1);
|
|
1094
|
+
|
|
1095
|
+
if (cursor) {
|
|
1096
|
+
query = query.lt('started_at', cursor);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const { data, error } = await query;
|
|
1100
|
+
if (error) throwSupabaseError('getSessionsByScope', error);
|
|
1101
|
+
|
|
1102
|
+
const rows = data ?? [];
|
|
1103
|
+
const hasMore = rows.length > limit;
|
|
1104
|
+
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
1105
|
+
const sessions = page.map(
|
|
1106
|
+
(row) => toCamelCase(row as Record<string, unknown>) as unknown as Session,
|
|
1107
|
+
);
|
|
1108
|
+
const nextCursor = hasMore && page.length > 0
|
|
1109
|
+
? (page[page.length - 1] as Record<string, unknown>)['started_at'] as string
|
|
1110
|
+
: null;
|
|
1111
|
+
|
|
1112
|
+
return { data: sessions, cursor: nextCursor, hasMore };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ---------------------------------------------------------------------------
|
|
1116
|
+
// Webhooks
|
|
1117
|
+
// ---------------------------------------------------------------------------
|
|
1118
|
+
|
|
1119
|
+
async createWebhook(
|
|
1120
|
+
webhook: CreateWebhook & { id: string; secretHash: string; signingKey: string },
|
|
1121
|
+
): Promise<Webhook> {
|
|
1122
|
+
// CreateWebhook has `secret` but we don't store it separately — signingKey holds the raw
|
|
1123
|
+
// secret for HMAC signing, and secretHash holds the hashed version.
|
|
1124
|
+
// Strip `secret` before inserting; it's not a DB column.
|
|
1125
|
+
const { secret: _secret, ...rest } = webhook;
|
|
1126
|
+
const row = toSnakeCase(rest as unknown as Record<string, unknown>);
|
|
1127
|
+
const { data, error } = await this.client
|
|
1128
|
+
.from('webhooks')
|
|
1129
|
+
.insert(row)
|
|
1130
|
+
.select()
|
|
1131
|
+
.single();
|
|
1132
|
+
if (error) throwSupabaseError('createWebhook', error);
|
|
1133
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Webhook;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
async getWebhook(tenantId: string, id: string): Promise<Webhook | null> {
|
|
1137
|
+
const { data, error } = await this.client
|
|
1138
|
+
.from('webhooks')
|
|
1139
|
+
.select('*')
|
|
1140
|
+
.eq('tenant_id', tenantId)
|
|
1141
|
+
.eq('id', id)
|
|
1142
|
+
.maybeSingle();
|
|
1143
|
+
if (error) throwSupabaseError('getWebhook', error);
|
|
1144
|
+
if (!data) return null;
|
|
1145
|
+
return toCamelCase(data as Record<string, unknown>) as unknown as Webhook;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
async getWebhooksForTenant(tenantId: string): Promise<Webhook[]> {
|
|
1149
|
+
const { data, error } = await this.client
|
|
1150
|
+
.from('webhooks')
|
|
1151
|
+
.select('*')
|
|
1152
|
+
.eq('tenant_id', tenantId);
|
|
1153
|
+
if (error) throwSupabaseError('getWebhooksForTenant', error);
|
|
1154
|
+
return (data ?? []).map(
|
|
1155
|
+
(row) => toCamelCase(row as Record<string, unknown>) as unknown as Webhook,
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async getWebhooksByEvent(tenantId: string, event: string): Promise<Webhook[]> {
|
|
1160
|
+
const { data, error } = await this.client
|
|
1161
|
+
.from('webhooks')
|
|
1162
|
+
.select('*')
|
|
1163
|
+
.eq('tenant_id', tenantId)
|
|
1164
|
+
.eq('active', true)
|
|
1165
|
+
.contains('events', [event]);
|
|
1166
|
+
if (error) throwSupabaseError('getWebhooksByEvent', error);
|
|
1167
|
+
return (data ?? []).map(
|
|
1168
|
+
(row) => toCamelCase(row as Record<string, unknown>) as unknown as Webhook,
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
async deleteWebhook(tenantId: string, id: string): Promise<void> {
|
|
1173
|
+
const { error } = await this.client
|
|
1174
|
+
.from('webhooks')
|
|
1175
|
+
.delete()
|
|
1176
|
+
.eq('tenant_id', tenantId)
|
|
1177
|
+
.eq('id', id);
|
|
1178
|
+
if (error) throwSupabaseError('deleteWebhook', error);
|
|
1179
|
+
}
|
|
1180
|
+
}
|