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