@perceo/supabase 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/index.js ADDED
@@ -0,0 +1,809 @@
1
+ // src/index.ts
2
+ import { createClient as createClient2 } from "@supabase/supabase-js";
3
+
4
+ // src/client.ts
5
+ import { createClient } from "@supabase/supabase-js";
6
+ import { createHash, randomBytes } from "crypto";
7
+ var PerceoDataClient = class _PerceoDataClient {
8
+ supabase;
9
+ projectId;
10
+ channels = /* @__PURE__ */ new Map();
11
+ constructor(config) {
12
+ if ("supabase" in config) {
13
+ this.supabase = config.supabase;
14
+ this.projectId = config.projectId ?? null;
15
+ } else {
16
+ this.supabase = createClient(config.supabaseUrl, config.supabaseKey, {
17
+ auth: {
18
+ autoRefreshToken: true,
19
+ persistSession: false
20
+ }
21
+ });
22
+ this.projectId = config.projectId ?? null;
23
+ }
24
+ }
25
+ /**
26
+ * Create a client from environment variables
27
+ */
28
+ static fromEnv(projectId) {
29
+ const supabaseUrl = process.env.PERCEO_SUPABASE_URL;
30
+ const supabaseKey = process.env.PERCEO_SUPABASE_SERVICE_ROLE_KEY || process.env.PERCEO_SUPABASE_ANON_KEY;
31
+ if (!supabaseUrl || !supabaseKey) {
32
+ throw new Error("PERCEO_SUPABASE_URL and PERCEO_SUPABASE_ANON_KEY (or SERVICE_ROLE_KEY) are required");
33
+ }
34
+ return new _PerceoDataClient({
35
+ supabaseUrl,
36
+ supabaseKey,
37
+ projectId
38
+ });
39
+ }
40
+ /**
41
+ * Create a client that uses a specific user session for auth.
42
+ * All queries made through this client will run as that user.
43
+ */
44
+ static async fromUserSession(params) {
45
+ const supabase = createClient(params.supabaseUrl, params.supabaseKey, {
46
+ auth: {
47
+ autoRefreshToken: true,
48
+ persistSession: false
49
+ }
50
+ });
51
+ const { error } = await supabase.auth.setSession({
52
+ access_token: params.accessToken,
53
+ refresh_token: params.refreshToken
54
+ });
55
+ if (error) {
56
+ throw error;
57
+ }
58
+ return new _PerceoDataClient({
59
+ supabase,
60
+ projectId: params.projectId
61
+ });
62
+ }
63
+ /**
64
+ * Get the underlying Supabase client (for advanced operations)
65
+ */
66
+ getSupabaseClient() {
67
+ return this.supabase;
68
+ }
69
+ /**
70
+ * Alias for getSupabaseClient() for backward compatibility
71
+ */
72
+ getClient() {
73
+ return this.supabase;
74
+ }
75
+ // ==========================================================================
76
+ // Project Secrets
77
+ // ==========================================================================
78
+ /**
79
+ * Store or update a project secret (LLM API keys, etc.)
80
+ * Only accessible with service role or by project admins
81
+ */
82
+ async upsertProjectSecret(projectId, keyName, value, createdBy) {
83
+ const { data, error } = await this.supabase.rpc("upsert_project_secret", {
84
+ p_project_id: projectId,
85
+ p_key_name: keyName,
86
+ p_value: value,
87
+ p_created_by: createdBy ?? null
88
+ });
89
+ if (error) throw error;
90
+ return data;
91
+ }
92
+ /**
93
+ * Get a project secret (service role only)
94
+ * This is typically called by backend services, not by end users
95
+ */
96
+ async getProjectSecret(projectId, keyName) {
97
+ const { data, error } = await this.supabase.rpc("get_project_secret", {
98
+ p_project_id: projectId,
99
+ p_key_name: keyName
100
+ });
101
+ if (error) {
102
+ if (error.message.includes("Insufficient permissions")) {
103
+ return null;
104
+ }
105
+ throw error;
106
+ }
107
+ return data;
108
+ }
109
+ // ==========================================================================
110
+ // Projects
111
+ // ==========================================================================
112
+ async getProject(id) {
113
+ const { data, error } = await this.supabase.from("projects").select("*").eq("id", id).single();
114
+ if (error) throw error;
115
+ return data;
116
+ }
117
+ async getProjectByName(name) {
118
+ const { data, error } = await this.supabase.from("projects").select("*").eq("name", name).single();
119
+ if (error && error.code !== "PGRST116") throw error;
120
+ return data;
121
+ }
122
+ async createProject(project) {
123
+ const { data, error } = await this.supabase.from("projects").insert(project).select().single();
124
+ if (error) throw error;
125
+ return data;
126
+ }
127
+ async upsertProject(project) {
128
+ const { data, error } = await this.supabase.from("projects").upsert(project, { onConflict: "name" }).select().single();
129
+ if (error) throw error;
130
+ return data;
131
+ }
132
+ async updateProject(projectId, updates) {
133
+ const { data, error } = await this.supabase.from("projects").update(updates).eq("id", projectId).select().single();
134
+ if (error) throw error;
135
+ return data;
136
+ }
137
+ /**
138
+ * Delete a project and all associated data (cascades to flows, steps, personas, API keys, etc.).
139
+ * Requires project admin/owner (enforced by RLS).
140
+ */
141
+ async deleteProject(projectId) {
142
+ const { error } = await this.supabase.from("projects").delete().eq("id", projectId);
143
+ if (error) throw error;
144
+ }
145
+ // ==========================================================================
146
+ // Project members (access control)
147
+ // ==========================================================================
148
+ /**
149
+ * Get the current user's role for a project, or null if not a member.
150
+ * Use with a client that has user session set (e.g. fromUserSession).
151
+ */
152
+ async getProjectMemberRole(projectId) {
153
+ const {
154
+ data: { user }
155
+ } = await this.supabase.auth.getUser();
156
+ if (!user) return null;
157
+ const { data, error } = await this.supabase.from("project_members").select("role").eq("project_id", projectId).eq("user_id", user.id).maybeSingle();
158
+ if (error) throw error;
159
+ return data?.role ?? null;
160
+ }
161
+ /**
162
+ * List members of a project. Requires project membership to view.
163
+ */
164
+ async getProjectMembers(projectId) {
165
+ const pid = projectId ?? this.projectId;
166
+ if (!pid) throw new Error("Project ID required");
167
+ const { data, error } = await this.supabase.from("project_members").select("*").eq("project_id", pid).order("role").order("created_at");
168
+ if (error) throw error;
169
+ return data ?? [];
170
+ }
171
+ /**
172
+ * Add a user as a project member. Requires project admin or owner role.
173
+ */
174
+ async addProjectMember(projectId, userId, role) {
175
+ const { data, error } = await this.supabase.from("project_members").insert({ project_id: projectId, user_id: userId, role }).select().single();
176
+ if (error) throw error;
177
+ return data;
178
+ }
179
+ /**
180
+ * Remove a member from a project. Requires project admin or owner role.
181
+ */
182
+ async removeProjectMember(projectId, userId) {
183
+ const { error } = await this.supabase.from("project_members").delete().eq("project_id", projectId).eq("user_id", userId);
184
+ if (error) throw error;
185
+ }
186
+ // ==========================================================================
187
+ // Personas
188
+ // ==========================================================================
189
+ async getPersonas(projectId) {
190
+ const pid = projectId ?? this.projectId;
191
+ if (!pid) throw new Error("Project ID required");
192
+ const { data, error } = await this.supabase.from("personas").select("*").eq("project_id", pid).order("name");
193
+ if (error) throw error;
194
+ return data ?? [];
195
+ }
196
+ async createPersona(persona) {
197
+ const { data, error } = await this.supabase.from("personas").insert(persona).select().single();
198
+ if (error) throw error;
199
+ return data;
200
+ }
201
+ async getPersonasBySource(source, projectId) {
202
+ const pid = projectId ?? this.projectId;
203
+ if (!pid) throw new Error("Project ID required");
204
+ const { data, error } = await this.supabase.from("personas").select("*").eq("project_id", pid).eq("source", source).order("name");
205
+ if (error) throw error;
206
+ return data ?? [];
207
+ }
208
+ async createUserConfiguredPersonas(personas, projectId) {
209
+ const pid = projectId ?? this.projectId;
210
+ if (!pid) throw new Error("Project ID required");
211
+ const personasWithSource = personas.map((persona) => ({
212
+ ...persona,
213
+ project_id: pid,
214
+ source: "user_configured"
215
+ }));
216
+ const { data, error } = await this.supabase.from("personas").insert(personasWithSource).select();
217
+ if (error) throw error;
218
+ return data ?? [];
219
+ }
220
+ async deleteAutoGeneratedPersonas(projectId) {
221
+ const pid = projectId ?? this.projectId;
222
+ if (!pid) throw new Error("Project ID required");
223
+ const { error } = await this.supabase.from("personas").delete().eq("project_id", pid).eq("source", "auto_generated");
224
+ if (error) throw error;
225
+ }
226
+ // ==========================================================================
227
+ // Flows
228
+ // ==========================================================================
229
+ async getFlows(projectId) {
230
+ const pid = projectId ?? this.projectId;
231
+ if (!pid) throw new Error("Project ID required");
232
+ const { data, error } = await this.supabase.from("flows").select("*").eq("project_id", pid).eq("is_active", true).order("priority", { ascending: true }).order("name");
233
+ if (error) throw error;
234
+ return data ?? [];
235
+ }
236
+ async getFlow(id) {
237
+ const { data, error } = await this.supabase.from("flows").select("*").eq("id", id).single();
238
+ if (error && error.code !== "PGRST116") throw error;
239
+ return data;
240
+ }
241
+ async getFlowByName(name, projectId) {
242
+ const pid = projectId ?? this.projectId;
243
+ if (!pid) throw new Error("Project ID required");
244
+ const { data, error } = await this.supabase.from("flows").select("*").eq("project_id", pid).eq("name", name).single();
245
+ if (error && error.code !== "PGRST116") throw error;
246
+ return data;
247
+ }
248
+ async getFlowWithSteps(id) {
249
+ const { data: flow, error: flowError } = await this.supabase.from("flows").select("*").eq("id", id).single();
250
+ if (flowError) throw flowError;
251
+ if (!flow) return null;
252
+ const { data: steps, error: stepsError } = await this.supabase.from("steps").select("*").eq("flow_id", id).order("sequence_order");
253
+ if (stepsError) throw stepsError;
254
+ return { ...flow, steps: steps ?? [] };
255
+ }
256
+ async getFlowWithMetrics(id) {
257
+ const { data: flow, error: flowError } = await this.supabase.from("flows").select("*").eq("id", id).single();
258
+ if (flowError) throw flowError;
259
+ if (!flow) return null;
260
+ const { data: metrics, error: metricsError } = await this.supabase.from("flow_metrics").select("*").eq("flow_id", id).single();
261
+ if (metricsError && metricsError.code !== "PGRST116") throw metricsError;
262
+ return { ...flow, metrics: metrics ?? null };
263
+ }
264
+ async createFlow(flow) {
265
+ const { data, error } = await this.supabase.from("flows").insert(flow).select().single();
266
+ if (error) throw error;
267
+ return data;
268
+ }
269
+ async updateFlow(id, updates) {
270
+ const { data, error } = await this.supabase.from("flows").update(updates).eq("id", id).select().single();
271
+ if (error) throw error;
272
+ return data;
273
+ }
274
+ async upsertFlow(flow) {
275
+ const { data, error } = await this.supabase.from("flows").upsert(flow, { onConflict: "project_id,name" }).select().single();
276
+ if (error) throw error;
277
+ return data;
278
+ }
279
+ async upsertFlows(flows) {
280
+ const { data, error } = await this.supabase.from("flows").upsert(flows, { onConflict: "project_id,name" }).select();
281
+ if (error) throw error;
282
+ return data ?? [];
283
+ }
284
+ /**
285
+ * Get flows affected by recent code changes
286
+ */
287
+ async getAffectedFlows(projectId) {
288
+ const pid = projectId ?? this.projectId;
289
+ if (!pid) throw new Error("Project ID required");
290
+ const { data, error } = await this.supabase.from("flows").select("*").eq("project_id", pid).eq("is_active", true).not("affected_by_changes", "eq", "{}").order("risk_score", { ascending: false });
291
+ if (error) throw error;
292
+ return data ?? [];
293
+ }
294
+ /**
295
+ * Mark flows as affected by a code change
296
+ */
297
+ async markFlowsAffected(flowIds, changeId, riskScoreIncrement = 0.1) {
298
+ for (const flowId of flowIds) {
299
+ const { data: flow } = await this.supabase.from("flows").select("affected_by_changes, risk_score").eq("id", flowId).single();
300
+ if (flow) {
301
+ const currentChanges = flow.affected_by_changes ?? [];
302
+ const newRiskScore = Math.min(1, (flow.risk_score ?? 0) + riskScoreIncrement);
303
+ await this.supabase.from("flows").update({
304
+ affected_by_changes: [...currentChanges, changeId],
305
+ risk_score: newRiskScore
306
+ }).eq("id", flowId);
307
+ }
308
+ }
309
+ }
310
+ /**
311
+ * Clear affected changes from flows (e.g., after tests pass)
312
+ */
313
+ async clearAffectedFlows(flowIds) {
314
+ await this.supabase.from("flows").update({
315
+ affected_by_changes: [],
316
+ risk_score: 0
317
+ }).in("id", flowIds);
318
+ }
319
+ // ==========================================================================
320
+ // Steps
321
+ // ==========================================================================
322
+ async getSteps(flowId) {
323
+ const { data, error } = await this.supabase.from("steps").select("*").eq("flow_id", flowId).order("sequence_order");
324
+ if (error) throw error;
325
+ return data ?? [];
326
+ }
327
+ async createStep(step) {
328
+ const { data, error } = await this.supabase.from("steps").insert(step).select().single();
329
+ if (error) throw error;
330
+ return data;
331
+ }
332
+ async createSteps(steps) {
333
+ const { data, error } = await this.supabase.from("steps").insert(steps).select();
334
+ if (error) throw error;
335
+ return data ?? [];
336
+ }
337
+ // ==========================================================================
338
+ // Test Runs
339
+ // ==========================================================================
340
+ async getTestRuns(flowId, limit = 20) {
341
+ const { data, error } = await this.supabase.from("test_runs").select("*").eq("flow_id", flowId).order("created_at", { ascending: false }).limit(limit);
342
+ if (error) throw error;
343
+ return data ?? [];
344
+ }
345
+ async getRecentTestRuns(projectId, limit = 50) {
346
+ const pid = projectId ?? this.projectId;
347
+ if (!pid) throw new Error("Project ID required");
348
+ const { data, error } = await this.supabase.from("test_runs").select("*").eq("project_id", pid).order("created_at", { ascending: false }).limit(limit);
349
+ if (error) throw error;
350
+ return data ?? [];
351
+ }
352
+ async createTestRun(testRun) {
353
+ const { data, error } = await this.supabase.from("test_runs").insert(testRun).select().single();
354
+ if (error) throw error;
355
+ return data;
356
+ }
357
+ async updateTestRun(id, updates) {
358
+ const { data, error } = await this.supabase.from("test_runs").update(updates).eq("id", id).select().single();
359
+ if (error) throw error;
360
+ return data;
361
+ }
362
+ // ==========================================================================
363
+ // Insights
364
+ // ==========================================================================
365
+ async getInsights(projectId, status) {
366
+ const pid = projectId ?? this.projectId;
367
+ if (!pid) throw new Error("Project ID required");
368
+ let query = this.supabase.from("insights").select("*").eq("project_id", pid).order("created_at", { ascending: false });
369
+ if (status) {
370
+ query = query.eq("status", status);
371
+ }
372
+ const { data, error } = await query;
373
+ if (error) throw error;
374
+ return data ?? [];
375
+ }
376
+ async getOpenInsights(projectId) {
377
+ return this.getInsights(projectId, "open");
378
+ }
379
+ async createInsight(insight) {
380
+ const { data, error } = await this.supabase.from("insights").insert(insight).select().single();
381
+ if (error) throw error;
382
+ return data;
383
+ }
384
+ async updateInsight(id, updates) {
385
+ const { data, error } = await this.supabase.from("insights").update(updates).eq("id", id).select().single();
386
+ if (error) throw error;
387
+ return data;
388
+ }
389
+ // ==========================================================================
390
+ // Code Changes
391
+ // ==========================================================================
392
+ async createCodeChange(change) {
393
+ const { data, error } = await this.supabase.from("code_changes").insert(change).select().single();
394
+ if (error) throw error;
395
+ return data;
396
+ }
397
+ async getCodeChange(baseSha, headSha, projectId) {
398
+ const pid = projectId ?? this.projectId;
399
+ if (!pid) throw new Error("Project ID required");
400
+ const { data, error } = await this.supabase.from("code_changes").select("*").eq("project_id", pid).eq("base_sha", baseSha).eq("head_sha", headSha).single();
401
+ if (error && error.code !== "PGRST116") throw error;
402
+ return data;
403
+ }
404
+ async updateCodeChangeAnalysis(id, analysis) {
405
+ const { data, error } = await this.supabase.from("code_changes").update({
406
+ ...analysis,
407
+ analyzed_at: (/* @__PURE__ */ new Date()).toISOString()
408
+ }).eq("id", id).select().single();
409
+ if (error) throw error;
410
+ return data;
411
+ }
412
+ // ==========================================================================
413
+ // Events (Realtime Event Bus)
414
+ // ==========================================================================
415
+ async publishEvent(event) {
416
+ const { data, error } = await this.supabase.from("events").insert({
417
+ ...event,
418
+ project_id: event.project_id ?? this.projectId ?? void 0
419
+ }).select().single();
420
+ if (error) throw error;
421
+ return data;
422
+ }
423
+ /**
424
+ * Publish a flow change event
425
+ */
426
+ async publishFlowsAffected(changeId, affectedFlows, source = "observer") {
427
+ return this.publishEvent({
428
+ type: "flows.affected",
429
+ payload: {
430
+ changeId,
431
+ flows: affectedFlows,
432
+ timestamp: Date.now()
433
+ },
434
+ source
435
+ });
436
+ }
437
+ /**
438
+ * Publish a test status event
439
+ */
440
+ async publishTestStatus(testRunId, flowId, status, source = "coordinator") {
441
+ return this.publishEvent({
442
+ type: `test.${status}`,
443
+ payload: {
444
+ testRunId,
445
+ flowId,
446
+ status,
447
+ timestamp: Date.now()
448
+ },
449
+ source
450
+ });
451
+ }
452
+ // ==========================================================================
453
+ // Predictions
454
+ // ==========================================================================
455
+ async createPrediction(prediction) {
456
+ const { data, error } = await this.supabase.from("predictions").insert(prediction).select().single();
457
+ if (error) throw error;
458
+ return data;
459
+ }
460
+ async getUnvalidatedPredictions(flowId) {
461
+ const { data, error } = await this.supabase.from("predictions").select("*").eq("flow_id", flowId).is("validated_at", null).order("created_at", { ascending: false });
462
+ if (error) throw error;
463
+ return data ?? [];
464
+ }
465
+ // ==========================================================================
466
+ // Flow Metrics
467
+ // ==========================================================================
468
+ async getFlowMetrics(flowId) {
469
+ const { data, error } = await this.supabase.from("flow_metrics").select("*").eq("flow_id", flowId).single();
470
+ if (error && error.code !== "PGRST116") throw error;
471
+ return data;
472
+ }
473
+ async upsertFlowMetrics(flowId, metrics) {
474
+ const { data, error } = await this.supabase.from("flow_metrics").upsert({ flow_id: flowId, ...metrics }, { onConflict: "flow_id" }).select().single();
475
+ if (error) throw error;
476
+ return data;
477
+ }
478
+ // ==========================================================================
479
+ // API Keys
480
+ // ==========================================================================
481
+ /**
482
+ * Generate a new API key for a project
483
+ * Returns the full key (only shown once) and the created key record
484
+ */
485
+ async createApiKey(projectId, options) {
486
+ const keyBytes = randomBytes(32);
487
+ const key = `prc_${keyBytes.toString("base64url")}`;
488
+ const keyPrefix = key.substring(0, 12);
489
+ const keyHash = createHash("sha256").update(key).digest("hex");
490
+ const insertData = {
491
+ project_id: projectId,
492
+ name: options.name,
493
+ key_hash: keyHash,
494
+ key_prefix: keyPrefix,
495
+ scopes: options.scopes,
496
+ created_by: options.createdBy ?? null,
497
+ expires_at: options.expiresAt?.toISOString() ?? null
498
+ };
499
+ const { data, error } = await this.supabase.from("project_api_keys").insert(insertData).select().single();
500
+ if (error) throw error;
501
+ return {
502
+ key,
503
+ keyRecord: data
504
+ };
505
+ }
506
+ /**
507
+ * Get all API keys for a project (metadata only, no hashes)
508
+ */
509
+ async getApiKeys(projectId) {
510
+ const pid = projectId ?? this.projectId;
511
+ if (!pid) throw new Error("Project ID required");
512
+ const { data, error } = await this.supabase.from("project_api_keys").select("id, project_id, name, key_prefix, scopes, created_by, created_at, last_used_at, last_used_ip, expires_at, revoked_at, revoked_by, revocation_reason").eq("project_id", pid).order("created_at", { ascending: false });
513
+ if (error) throw error;
514
+ return data ?? [];
515
+ }
516
+ /**
517
+ * Get active (non-revoked, non-expired) API keys for a project
518
+ */
519
+ async getActiveApiKeys(projectId) {
520
+ const pid = projectId ?? this.projectId;
521
+ if (!pid) throw new Error("Project ID required");
522
+ const { data, error } = await this.supabase.from("project_api_keys").select("id, project_id, name, key_prefix, scopes, created_by, created_at, last_used_at, last_used_ip, expires_at, revoked_at, revoked_by, revocation_reason").eq("project_id", pid).is("revoked_at", null).or(`expires_at.is.null,expires_at.gt.${(/* @__PURE__ */ new Date()).toISOString()}`).order("created_at", { ascending: false });
523
+ if (error) throw error;
524
+ return data ?? [];
525
+ }
526
+ /**
527
+ * Validate an API key and return the project info if valid
528
+ */
529
+ async validateApiKey(key) {
530
+ if (!key.startsWith("prc_")) {
531
+ return null;
532
+ }
533
+ const keyPrefix = key.substring(0, 12);
534
+ const keyHash = createHash("sha256").update(key).digest("hex");
535
+ const { data, error } = await this.supabase.rpc("validate_api_key", {
536
+ p_key_prefix: keyPrefix,
537
+ p_key_hash: keyHash
538
+ });
539
+ if (error) throw error;
540
+ if (!data || data.length === 0) return null;
541
+ const result = data[0];
542
+ await this.supabase.rpc("record_api_key_usage", {
543
+ p_key_id: result.key_id
544
+ });
545
+ return {
546
+ projectId: result.project_id,
547
+ scopes: result.scopes,
548
+ keyId: result.key_id
549
+ };
550
+ }
551
+ /**
552
+ * Revoke an API key
553
+ */
554
+ async revokeApiKey(keyId, options) {
555
+ const { data, error } = await this.supabase.from("project_api_keys").update({
556
+ revoked_at: (/* @__PURE__ */ new Date()).toISOString(),
557
+ revoked_by: options?.revokedBy ?? null,
558
+ revocation_reason: options?.reason ?? null
559
+ }).eq("id", keyId).select().single();
560
+ if (error) throw error;
561
+ return data;
562
+ }
563
+ /**
564
+ * Delete an API key permanently
565
+ */
566
+ async deleteApiKey(keyId) {
567
+ const { error } = await this.supabase.from("project_api_keys").delete().eq("id", keyId);
568
+ if (error) throw error;
569
+ }
570
+ /**
571
+ * Check if an API key has a specific scope
572
+ */
573
+ async hasApiKeyScope(keyId, scope) {
574
+ const { data, error } = await this.supabase.rpc("has_api_key_scope", {
575
+ p_key_id: keyId,
576
+ p_scope: scope
577
+ });
578
+ if (error) throw error;
579
+ return data;
580
+ }
581
+ // ==========================================================================
582
+ // Realtime Subscriptions
583
+ // ==========================================================================
584
+ /**
585
+ * Subscribe to flow changes (via Postgres CDC)
586
+ */
587
+ subscribeToFlows(projectId, callback) {
588
+ const channelName = `flows-${projectId}`;
589
+ if (this.channels.has(channelName)) {
590
+ return this.channels.get(channelName);
591
+ }
592
+ const channel = this.supabase.channel(channelName).on(
593
+ "postgres_changes",
594
+ {
595
+ event: "*",
596
+ schema: "public",
597
+ table: "flows",
598
+ filter: `project_id=eq.${projectId}`
599
+ },
600
+ (payload) => {
601
+ callback(payload);
602
+ }
603
+ ).subscribe();
604
+ this.channels.set(channelName, channel);
605
+ return channel;
606
+ }
607
+ /**
608
+ * Subscribe to test run updates
609
+ */
610
+ subscribeToTestRuns(projectId, callback) {
611
+ const channelName = `test-runs-${projectId}`;
612
+ if (this.channels.has(channelName)) {
613
+ return this.channels.get(channelName);
614
+ }
615
+ const channel = this.supabase.channel(channelName).on(
616
+ "postgres_changes",
617
+ {
618
+ event: "*",
619
+ schema: "public",
620
+ table: "test_runs",
621
+ filter: `project_id=eq.${projectId}`
622
+ },
623
+ (payload) => {
624
+ callback(payload);
625
+ }
626
+ ).subscribe();
627
+ this.channels.set(channelName, channel);
628
+ return channel;
629
+ }
630
+ /**
631
+ * Subscribe to new insights
632
+ */
633
+ subscribeToInsights(projectId, callback) {
634
+ const channelName = `insights-${projectId}`;
635
+ if (this.channels.has(channelName)) {
636
+ return this.channels.get(channelName);
637
+ }
638
+ const channel = this.supabase.channel(channelName).on(
639
+ "postgres_changes",
640
+ {
641
+ event: "*",
642
+ schema: "public",
643
+ table: "insights",
644
+ filter: `project_id=eq.${projectId}`
645
+ },
646
+ (payload) => {
647
+ callback(payload);
648
+ }
649
+ ).subscribe();
650
+ this.channels.set(channelName, channel);
651
+ return channel;
652
+ }
653
+ /**
654
+ * Subscribe to events (event bus)
655
+ */
656
+ subscribeToEvents(projectId, eventTypes, callback) {
657
+ const channelName = `events-${projectId}-${eventTypes?.join("-") ?? "all"}`;
658
+ if (this.channels.has(channelName)) {
659
+ return this.channels.get(channelName);
660
+ }
661
+ const channel = this.supabase.channel(channelName).on(
662
+ "postgres_changes",
663
+ {
664
+ event: "INSERT",
665
+ schema: "public",
666
+ table: "events",
667
+ filter: `project_id=eq.${projectId}`
668
+ },
669
+ (payload) => {
670
+ const event = payload.new;
671
+ if (!eventTypes || eventTypes.includes(event.type)) {
672
+ callback(payload);
673
+ }
674
+ }
675
+ ).subscribe();
676
+ this.channels.set(channelName, channel);
677
+ return channel;
678
+ }
679
+ /**
680
+ * Broadcast ephemeral messages (not persisted)
681
+ */
682
+ broadcast(channelName, event, payload) {
683
+ const channel = this.supabase.channel(channelName);
684
+ channel.send({
685
+ type: "broadcast",
686
+ event,
687
+ payload
688
+ });
689
+ }
690
+ /**
691
+ * Subscribe to broadcast messages
692
+ */
693
+ subscribeToBroadcast(channelName, event, callback) {
694
+ const fullChannelName = `broadcast-${channelName}`;
695
+ if (this.channels.has(fullChannelName)) {
696
+ return this.channels.get(fullChannelName);
697
+ }
698
+ const channel = this.supabase.channel(fullChannelName).on("broadcast", { event }, ({ payload }) => {
699
+ callback(payload);
700
+ }).subscribe();
701
+ this.channels.set(fullChannelName, channel);
702
+ return channel;
703
+ }
704
+ /**
705
+ * Unsubscribe from a channel
706
+ */
707
+ async unsubscribe(channelName) {
708
+ const channel = this.channels.get(channelName);
709
+ if (channel) {
710
+ await channel.unsubscribe();
711
+ this.channels.delete(channelName);
712
+ }
713
+ }
714
+ /**
715
+ * Unsubscribe from all channels
716
+ */
717
+ async unsubscribeAll() {
718
+ for (const [name, channel] of this.channels) {
719
+ await channel.unsubscribe();
720
+ this.channels.delete(name);
721
+ }
722
+ }
723
+ /**
724
+ * Cleanup - call when done with the client
725
+ */
726
+ async cleanup() {
727
+ await this.unsubscribeAll();
728
+ }
729
+ };
730
+
731
+ // src/index.ts
732
+ var DEFAULT_SUPABASE_URL = "https://lygslnolucoidnhaitdn.supabase.co";
733
+ var DEFAULT_SUPABASE_ANON_KEY = process.env.PERCEO_SUPABASE_ANON_KEY || "sb_publishable_8Wj8bSM7drJH6mXp6NM7SQ_GcyE9pZb";
734
+ function getSupabaseUrl() {
735
+ return process.env.PERCEO_SUPABASE_URL || DEFAULT_SUPABASE_URL;
736
+ }
737
+ function getSupabaseAnonKey() {
738
+ const key = process.env.PERCEO_SUPABASE_ANON_KEY || DEFAULT_SUPABASE_ANON_KEY;
739
+ if (!key) {
740
+ throw new Error(
741
+ "PERCEO_SUPABASE_ANON_KEY is not configured. This should be embedded in the package for Perceo Cloud users. For self-hosted, set PERCEO_SUPABASE_ANON_KEY environment variable."
742
+ );
743
+ }
744
+ return key;
745
+ }
746
+ function getSupabaseServiceRoleKey() {
747
+ return process.env.PERCEO_SUPABASE_SERVICE_ROLE_KEY;
748
+ }
749
+ function createSupabaseAuthClient() {
750
+ return createClient2(getSupabaseUrl(), getSupabaseAnonKey(), {
751
+ auth: {
752
+ autoRefreshToken: true,
753
+ persistSession: false,
754
+ detectSessionInUrl: false
755
+ }
756
+ });
757
+ }
758
+ function parseHashParams(hash) {
759
+ const params = {};
760
+ if (!hash || !hash.startsWith("#")) return params;
761
+ const query = hash.slice(1);
762
+ for (const part of query.split("&")) {
763
+ const [key, value] = part.split("=").map((s) => decodeURIComponent(s.replace(/\+/g, " ")));
764
+ if (key && value) params[key] = value;
765
+ }
766
+ return params;
767
+ }
768
+ async function sessionFromRedirectUrl(supabase, redirectUrl) {
769
+ const hash = redirectUrl.includes("#") ? redirectUrl.slice(redirectUrl.indexOf("#")) : "";
770
+ const params = parseHashParams(hash);
771
+ const access_token = params.access_token;
772
+ const refresh_token = params.refresh_token;
773
+ const expires_in = params.expires_in ? parseInt(params.expires_in, 10) : 3600;
774
+ if (!access_token || !refresh_token) {
775
+ throw new Error("Redirect URL did not contain access_token and refresh_token");
776
+ }
777
+ const { data, error } = await supabase.auth.setSession({
778
+ access_token,
779
+ refresh_token
780
+ });
781
+ if (error) throw error;
782
+ const session = data.session;
783
+ if (!session) throw new Error("No session returned");
784
+ const expires_at = session.expires_at ?? Math.floor(Date.now() / 1e3) + expires_in;
785
+ return {
786
+ access_token: session.access_token,
787
+ refresh_token: session.refresh_token ?? refresh_token,
788
+ expires_at,
789
+ supabaseUrl: getSupabaseUrl()
790
+ };
791
+ }
792
+ async function sendMagicLink(supabase, email, redirectUrl) {
793
+ const { error } = await supabase.auth.signInWithOtp({
794
+ email,
795
+ options: {
796
+ emailRedirectTo: redirectUrl
797
+ }
798
+ });
799
+ return { error: error ?? null };
800
+ }
801
+ export {
802
+ PerceoDataClient,
803
+ createSupabaseAuthClient,
804
+ getSupabaseAnonKey,
805
+ getSupabaseServiceRoleKey,
806
+ getSupabaseUrl,
807
+ sendMagicLink,
808
+ sessionFromRedirectUrl
809
+ };