@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.
Files changed (53) hide show
  1. package/dist/client.d.ts +7 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +8 -0
  4. package/dist/client.js.map +1 -0
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +3 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/storage.d.ts +126 -0
  10. package/dist/storage.d.ts.map +1 -0
  11. package/dist/storage.js +990 -0
  12. package/dist/storage.js.map +1 -0
  13. package/package.json +33 -0
  14. package/src/client.d.ts +7 -0
  15. package/src/client.d.ts.map +1 -0
  16. package/src/client.js +8 -0
  17. package/src/client.js.map +1 -0
  18. package/src/client.ts +13 -0
  19. package/src/index.d.ts +3 -0
  20. package/src/index.d.ts.map +1 -0
  21. package/src/index.js +3 -0
  22. package/src/index.js.map +1 -0
  23. package/src/index.ts +2 -0
  24. package/src/migrations/001_extensions.sql +4 -0
  25. package/src/migrations/002_create_tenants.sql +14 -0
  26. package/src/migrations/003_create_api_keys.sql +15 -0
  27. package/src/migrations/004_create_sessions.sql +18 -0
  28. package/src/migrations/005_create_extractions.sql +34 -0
  29. package/src/migrations/006_create_facts.sql +68 -0
  30. package/src/migrations/007_create_entities.sql +26 -0
  31. package/src/migrations/008_create_fact_entities.sql +9 -0
  32. package/src/migrations/009_create_edges.sql +22 -0
  33. package/src/migrations/010_create_triggers.sql +18 -0
  34. package/src/migrations/011_create_memory_accesses.sql +20 -0
  35. package/src/migrations/012_create_usage_records.sql +16 -0
  36. package/src/migrations/013_create_functions.sql +19 -0
  37. package/src/migrations/014_create_rls_policies.sql +50 -0
  38. package/src/migrations/015_create_rpc_functions.sql +109 -0
  39. package/src/migrations/016_alter_source_ref.sql +2 -0
  40. package/src/migrations/017_keyword_search_rpc.sql +75 -0
  41. package/src/migrations/018_graph_traverse_rpc.sql +106 -0
  42. package/src/migrations/019_create_webhooks.sql +22 -0
  43. package/src/migrations/020_compound_search_rpc.sql +100 -0
  44. package/src/migrations/021_match_entities_rpc.sql +39 -0
  45. package/src/migrations/022_get_facts_for_entities_rpc.sql +123 -0
  46. package/src/migrations/023_add_event_date.sql +8 -0
  47. package/src/migrations/024_add_source_chunk.sql +3 -0
  48. package/src/migrations/025_update_edge_types_add_signing_key.sql +14 -0
  49. package/src/storage.d.ts +126 -0
  50. package/src/storage.d.ts.map +1 -0
  51. package/src/storage.js +990 -0
  52. package/src/storage.js.map +1 -0
  53. 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
+ }