@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.d.ts +707 -0
- package/dist/index.js +809 -0
- package/package.json +33 -0
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
|
+
};
|