@lightupai/polaris 0.0.76 → 0.0.78
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/.github/workflows/cd.yml +77 -0
- package/docs/architecture.md +203 -0
- package/docs/product-critique-2026-06.md +136 -0
- package/docs/readiness-review-2026-06.md +130 -0
- package/hooks/capture-prompt.ts +60 -0
- package/hooks/capture-stop.ts +10 -3
- package/hooks/capture.sh +12 -0
- package/hooks/statusline.sh +8 -2
- package/package.json +1 -1
- package/scripts/backup.sh +71 -0
- package/src/bridge-discover-org.ts +30 -4
- package/src/cli/cli.ts +304 -34
- package/src/client/client.ts +44 -11
- package/src/daemon/daemon.ts +262 -38
- package/src/service/auth.ts +10 -1
- package/src/service/db.ts +338 -63
- package/src/service/server.ts +267 -11
- package/src/slack/bridge.ts +172 -34
- package/src/types.ts +22 -0
- package/src/web/app.ts +373 -2
- package/src/web/layout.ts +5 -1
- package/src/web/views.ts +420 -6
- package/tests/acl.test.ts +100 -0
- package/tests/annotations.test.ts +209 -0
- package/tests/bridge-discover.test.ts +70 -0
- package/tests/daemon-auth.test.ts +123 -0
- package/tests/event-lookup.test.ts +113 -0
- package/tests/label.test.ts +90 -0
- package/tests/pagination.test.ts +165 -0
- package/tests/recover.test.ts +83 -0
- package/tests/reply-attribution.test.ts +103 -0
- package/tests/search-acl.test.ts +117 -0
- package/tests/search.test.ts +145 -0
package/src/service/db.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import postgres from "postgres";
|
|
2
|
-
import type { PolarisEvent, Project, Session, ParticipantId } from "../types";
|
|
2
|
+
import type { Annotation, PolarisEvent, Project, Session, ParticipantId } from "../types";
|
|
3
3
|
|
|
4
4
|
export type Sql = postgres.Sql;
|
|
5
5
|
|
|
@@ -26,6 +26,11 @@ export interface User {
|
|
|
26
26
|
created_at: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
export interface ProjectMember {
|
|
30
|
+
participant_id: string;
|
|
31
|
+
role: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
// --- Schema ---
|
|
30
35
|
|
|
31
36
|
export async function createDb(connectionString?: string): Promise<Sql> {
|
|
@@ -38,11 +43,8 @@ export async function createDb(connectionString?: string): Promise<Sql> {
|
|
|
38
43
|
const SCHEMA_LOCK_KEY = 0x504c5253; // "PLRS"
|
|
39
44
|
|
|
40
45
|
export async function ensureSchema(sql: Sql): Promise<void> {
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// they don't race on `CREATE TABLE IF NOT EXISTS` — which isn't concurrency
|
|
44
|
-
// safe: two connections can both pass the existence check and then collide on
|
|
45
|
-
// the underlying pg_type creation (issue #117).
|
|
46
|
+
// Serialize concurrent callers (api + web start together) with an advisory lock on a
|
|
47
|
+
// reserved connection — concurrent CREATE TABLE IF NOT EXISTS races otherwise (issue #117).
|
|
46
48
|
const lock = await sql.reserve();
|
|
47
49
|
try {
|
|
48
50
|
await lock`SELECT pg_advisory_lock(${SCHEMA_LOCK_KEY}::bigint)`;
|
|
@@ -82,26 +84,12 @@ async function createSchemaTables(sql: Sql): Promise<void> {
|
|
|
82
84
|
)
|
|
83
85
|
`;
|
|
84
86
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
) as exists
|
|
87
|
+
await sql`
|
|
88
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
89
|
+
id TEXT PRIMARY KEY,
|
|
90
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
91
|
+
)
|
|
91
92
|
`;
|
|
92
|
-
if (!hasId) {
|
|
93
|
-
// Check if the old table exists at all
|
|
94
|
-
const [{ exists: hasTable }] = await sql`
|
|
95
|
-
SELECT EXISTS (
|
|
96
|
-
SELECT 1 FROM information_schema.tables WHERE table_name = 'projects'
|
|
97
|
-
) as exists
|
|
98
|
-
`;
|
|
99
|
-
if (hasTable) {
|
|
100
|
-
await sql`DROP TABLE IF EXISTS events`;
|
|
101
|
-
await sql`DROP TABLE IF EXISTS sessions`;
|
|
102
|
-
await sql`DROP TABLE IF EXISTS projects`;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
93
|
|
|
106
94
|
await sql`
|
|
107
95
|
CREATE TABLE IF NOT EXISTS projects (
|
|
@@ -147,6 +135,39 @@ async function createSchemaTables(sql: Sql): Promise<void> {
|
|
|
147
135
|
CREATE INDEX IF NOT EXISTS idx_events_session ON events(project_id, session, timestamp)
|
|
148
136
|
`;
|
|
149
137
|
|
|
138
|
+
// Curation annotations (stars, tags, decisions). Kept free of foreign keys.
|
|
139
|
+
await sql`
|
|
140
|
+
CREATE TABLE IF NOT EXISTS annotations (
|
|
141
|
+
id UUID PRIMARY KEY,
|
|
142
|
+
org_id TEXT NOT NULL,
|
|
143
|
+
event_id UUID,
|
|
144
|
+
project TEXT NOT NULL,
|
|
145
|
+
session TEXT NOT NULL,
|
|
146
|
+
participant_id TEXT,
|
|
147
|
+
kind TEXT NOT NULL,
|
|
148
|
+
value TEXT,
|
|
149
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
150
|
+
)
|
|
151
|
+
`;
|
|
152
|
+
|
|
153
|
+
await sql`
|
|
154
|
+
CREATE INDEX IF NOT EXISTS idx_annotations_session ON annotations(org_id, project, session)
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
await sql`
|
|
158
|
+
CREATE INDEX IF NOT EXISTS idx_annotations_event ON annotations(event_id)
|
|
159
|
+
`;
|
|
160
|
+
|
|
161
|
+
// Per-project ACL membership. Kept free of foreign keys (see annotations note above).
|
|
162
|
+
await sql`
|
|
163
|
+
CREATE TABLE IF NOT EXISTS project_members (
|
|
164
|
+
project_id UUID,
|
|
165
|
+
participant_id TEXT,
|
|
166
|
+
role TEXT,
|
|
167
|
+
PRIMARY KEY (project_id, participant_id)
|
|
168
|
+
)
|
|
169
|
+
`;
|
|
170
|
+
|
|
150
171
|
await sql`
|
|
151
172
|
CREATE TABLE IF NOT EXISTS plan_changes (
|
|
152
173
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -157,6 +178,27 @@ async function createSchemaTables(sql: Sql): Promise<void> {
|
|
|
157
178
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
158
179
|
)
|
|
159
180
|
`;
|
|
181
|
+
|
|
182
|
+
await runMigrations(sql);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Additive, idempotent migrations. Each statement uses IF NOT EXISTS so it is safe to
|
|
186
|
+
// re-run on every startup; schema_migrations records what has been applied. NEVER drop a table.
|
|
187
|
+
async function runMigrations(sql: Sql): Promise<void> {
|
|
188
|
+
const migrations: Array<{ id: string; run: () => Promise<unknown> }> = [
|
|
189
|
+
{
|
|
190
|
+
id: "001-sessions-label",
|
|
191
|
+
run: () => sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS label TEXT`,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: "002-projects-visibility",
|
|
195
|
+
run: () => sql`ALTER TABLE projects ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'org'`,
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
for (const migration of migrations) {
|
|
199
|
+
await migration.run();
|
|
200
|
+
await sql`INSERT INTO schema_migrations (id) VALUES (${migration.id}) ON CONFLICT (id) DO NOTHING`;
|
|
201
|
+
}
|
|
160
202
|
}
|
|
161
203
|
|
|
162
204
|
// --- Orgs ---
|
|
@@ -249,20 +291,26 @@ export async function getRecentSignups(sql: Sql, since: Date, limit = 10): Promi
|
|
|
249
291
|
|
|
250
292
|
// --- Projects (org-scoped) ---
|
|
251
293
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
INSERT INTO projects (org_id, name) VALUES (${orgId}, ${name})
|
|
255
|
-
RETURNING id, name, slack_channel_id, slack_channel_name, created_at
|
|
256
|
-
`;
|
|
294
|
+
// SELECT * (not an explicit column list) so reads tolerate pre-visibility-migration rows.
|
|
295
|
+
function rowToProject(row: postgres.Row): Project {
|
|
257
296
|
return {
|
|
258
297
|
id: row.id,
|
|
259
298
|
name: row.name,
|
|
260
299
|
slack_channel_id: row.slack_channel_id ?? null,
|
|
261
300
|
slack_channel_name: row.slack_channel_name ?? null,
|
|
301
|
+
visibility: row.visibility ?? "org",
|
|
262
302
|
created_at: row.created_at.toISOString(),
|
|
263
303
|
};
|
|
264
304
|
}
|
|
265
305
|
|
|
306
|
+
export async function createProject(sql: Sql, orgId: string, name: string): Promise<Project> {
|
|
307
|
+
const [row] = await sql`
|
|
308
|
+
INSERT INTO projects (org_id, name) VALUES (${orgId}, ${name})
|
|
309
|
+
RETURNING *
|
|
310
|
+
`;
|
|
311
|
+
return rowToProject(row);
|
|
312
|
+
}
|
|
313
|
+
|
|
266
314
|
export async function renameProject(sql: Sql, orgId: string, oldName: string, newName: string): Promise<void> {
|
|
267
315
|
await sql`
|
|
268
316
|
UPDATE projects SET name = ${newName}, slack_channel_name = ${newName}
|
|
@@ -279,31 +327,74 @@ export async function setProjectSlackChannel(sql: Sql, orgId: string, projectNam
|
|
|
279
327
|
|
|
280
328
|
export async function listProjects(sql: Sql, orgId: string): Promise<Project[]> {
|
|
281
329
|
const rows = await sql`
|
|
282
|
-
SELECT
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
return rows.map((r) => ({
|
|
286
|
-
id: r.id,
|
|
287
|
-
name: r.name,
|
|
288
|
-
slack_channel_id: r.slack_channel_id ?? null,
|
|
289
|
-
slack_channel_name: r.slack_channel_name ?? null,
|
|
290
|
-
created_at: r.created_at.toISOString(),
|
|
291
|
-
}));
|
|
330
|
+
SELECT * FROM projects WHERE org_id = ${orgId} ORDER BY created_at ASC
|
|
331
|
+
`;
|
|
332
|
+
return rows.map(rowToProject);
|
|
292
333
|
}
|
|
293
334
|
|
|
294
335
|
export async function getProject(sql: Sql, orgId: string, name: string): Promise<Project | null> {
|
|
295
336
|
const [row] = await sql`
|
|
296
|
-
SELECT
|
|
297
|
-
FROM projects WHERE org_id = ${orgId} AND name = ${name}
|
|
337
|
+
SELECT * FROM projects WHERE org_id = ${orgId} AND name = ${name}
|
|
298
338
|
`;
|
|
299
339
|
if (!row) return null;
|
|
300
|
-
return
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
340
|
+
return rowToProject(row);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// --- Project visibility & members (ACL) ---
|
|
344
|
+
|
|
345
|
+
export async function setProjectVisibility(sql: Sql, orgId: string, project: string, visibility: string): Promise<void> {
|
|
346
|
+
// Self-heal: resetTestData recreates projects without the visibility column, so make
|
|
347
|
+
// sure it exists (idempotent) before writing.
|
|
348
|
+
await sql`ALTER TABLE projects ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'org'`;
|
|
349
|
+
await sql`
|
|
350
|
+
UPDATE projects SET visibility = ${visibility}
|
|
351
|
+
WHERE org_id = ${orgId} AND name = ${project}
|
|
352
|
+
`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function addProjectMember(sql: Sql, orgId: string, project: string, participantId: string, role?: string): Promise<void> {
|
|
356
|
+
await sql`
|
|
357
|
+
INSERT INTO project_members (project_id, participant_id, role)
|
|
358
|
+
SELECT p.id, ${participantId}, ${role ?? null}
|
|
359
|
+
FROM projects p WHERE p.org_id = ${orgId} AND p.name = ${project}
|
|
360
|
+
ON CONFLICT (project_id, participant_id) DO UPDATE SET role = EXCLUDED.role
|
|
361
|
+
`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function removeProjectMember(sql: Sql, orgId: string, project: string, participantId: string): Promise<void> {
|
|
365
|
+
await sql`
|
|
366
|
+
DELETE FROM project_members
|
|
367
|
+
WHERE participant_id = ${participantId}
|
|
368
|
+
AND project_id = (SELECT id FROM projects WHERE org_id = ${orgId} AND name = ${project})
|
|
369
|
+
`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function listProjectMembers(sql: Sql, orgId: string, project: string): Promise<ProjectMember[]> {
|
|
373
|
+
const rows = await sql`
|
|
374
|
+
SELECT m.participant_id, m.role
|
|
375
|
+
FROM project_members m
|
|
376
|
+
JOIN projects p ON m.project_id = p.id
|
|
377
|
+
WHERE p.org_id = ${orgId} AND p.name = ${project}
|
|
378
|
+
ORDER BY m.participant_id ASC
|
|
379
|
+
`;
|
|
380
|
+
return rows.map((r) => ({ participant_id: r.participant_id, role: r.role ?? null }));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function userCanAccessProject(sql: Sql, orgId: string, project: string, participantId: string | null): Promise<boolean> {
|
|
384
|
+
// Default-open: anonymous callers, unknown projects, 'org' visibility, and any schema
|
|
385
|
+
// created before the visibility migration / project_members table all mean accessible.
|
|
386
|
+
if (!participantId) return true;
|
|
387
|
+
try {
|
|
388
|
+
const [row] = await sql`SELECT * FROM projects WHERE org_id = ${orgId} AND name = ${project}`;
|
|
389
|
+
if (!row) return true;
|
|
390
|
+
if ((row.visibility ?? "org") !== "members") return true;
|
|
391
|
+
const members = await sql`
|
|
392
|
+
SELECT 1 FROM project_members WHERE project_id = ${row.id} AND participant_id = ${participantId}
|
|
393
|
+
`;
|
|
394
|
+
return members.length > 0;
|
|
395
|
+
} catch {
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
307
398
|
}
|
|
308
399
|
|
|
309
400
|
// --- Sessions (org-scoped) ---
|
|
@@ -315,24 +406,27 @@ export async function createSession(
|
|
|
315
406
|
name: string,
|
|
316
407
|
driver: ParticipantId | null
|
|
317
408
|
): Promise<Session> {
|
|
409
|
+
// RETURNING * so this tolerates pre-label-migration session tables (label -> null).
|
|
318
410
|
const [row] = await sql`
|
|
319
411
|
INSERT INTO sessions (name, project_id, org_id, driver)
|
|
320
412
|
SELECT ${name}, p.id, ${orgId}, ${driver}
|
|
321
413
|
FROM projects p WHERE p.org_id = ${orgId} AND p.name = ${project}
|
|
322
|
-
RETURNING
|
|
414
|
+
RETURNING *
|
|
323
415
|
`;
|
|
324
416
|
if (!row) throw new Error(`Project not found: ${project}`);
|
|
325
417
|
return {
|
|
326
418
|
name: row.name,
|
|
327
419
|
project,
|
|
328
420
|
driver: row.driver,
|
|
421
|
+
label: row.label ?? null,
|
|
329
422
|
created_at: row.created_at.toISOString(),
|
|
330
423
|
};
|
|
331
424
|
}
|
|
332
425
|
|
|
333
426
|
export async function getSession(sql: Sql, orgId: string, project: string, name: string): Promise<Session | null> {
|
|
427
|
+
// s.* so this tolerates pre-label-migration session tables (label -> null).
|
|
334
428
|
const [row] = await sql`
|
|
335
|
-
SELECT s
|
|
429
|
+
SELECT s.*, p.name as project
|
|
336
430
|
FROM sessions s
|
|
337
431
|
JOIN projects p ON s.project_id = p.id
|
|
338
432
|
WHERE s.org_id = ${orgId} AND p.name = ${project} AND s.name = ${name}
|
|
@@ -342,6 +436,7 @@ export async function getSession(sql: Sql, orgId: string, project: string, name:
|
|
|
342
436
|
name: row.name,
|
|
343
437
|
project: row.project,
|
|
344
438
|
driver: row.driver,
|
|
439
|
+
label: row.label ?? null,
|
|
345
440
|
created_at: row.created_at.toISOString(),
|
|
346
441
|
};
|
|
347
442
|
}
|
|
@@ -349,14 +444,14 @@ export async function getSession(sql: Sql, orgId: string, project: string, name:
|
|
|
349
444
|
export async function listSessions(sql: Sql, orgId: string, project?: string): Promise<Session[]> {
|
|
350
445
|
const rows = project
|
|
351
446
|
? await sql`
|
|
352
|
-
SELECT s
|
|
447
|
+
SELECT s.*, p.name as project
|
|
353
448
|
FROM sessions s
|
|
354
449
|
JOIN projects p ON s.project_id = p.id
|
|
355
450
|
WHERE s.org_id = ${orgId} AND p.name = ${project}
|
|
356
451
|
ORDER BY s.created_at ASC
|
|
357
452
|
`
|
|
358
453
|
: await sql`
|
|
359
|
-
SELECT s
|
|
454
|
+
SELECT s.*, p.name as project
|
|
360
455
|
FROM sessions s
|
|
361
456
|
JOIN projects p ON s.project_id = p.id
|
|
362
457
|
WHERE s.org_id = ${orgId}
|
|
@@ -366,6 +461,7 @@ export async function listSessions(sql: Sql, orgId: string, project?: string): P
|
|
|
366
461
|
name: row.name,
|
|
367
462
|
project: row.project,
|
|
368
463
|
driver: row.driver,
|
|
464
|
+
label: row.label ?? null,
|
|
369
465
|
created_at: row.created_at.toISOString(),
|
|
370
466
|
}));
|
|
371
467
|
}
|
|
@@ -413,25 +509,28 @@ export async function clearDriver(sql: Sql, orgId: string, project: string, sess
|
|
|
413
509
|
`;
|
|
414
510
|
}
|
|
415
511
|
|
|
512
|
+
export async function setSessionLabel(sql: Sql, orgId: string, project: string, session: string, label: string): Promise<void> {
|
|
513
|
+
await sql`
|
|
514
|
+
UPDATE sessions SET label = ${label}
|
|
515
|
+
WHERE org_id = ${orgId} AND name = ${session}
|
|
516
|
+
AND project_id = (SELECT id FROM projects WHERE org_id = ${orgId} AND name = ${project})
|
|
517
|
+
`;
|
|
518
|
+
}
|
|
519
|
+
|
|
416
520
|
// --- Events (org-scoped) ---
|
|
417
521
|
|
|
418
522
|
export async function pushEvent(sql: Sql, orgId: string, event: PolarisEvent): Promise<void> {
|
|
419
|
-
await sql`
|
|
523
|
+
const result = await sql`
|
|
420
524
|
INSERT INTO events (id, org_id, project_id, session, timestamp, source, sender, payload)
|
|
421
525
|
SELECT ${event.id}, ${orgId}, p.id, ${event.session}, ${event.timestamp}, ${event.source}, ${event.sender}, ${sql.json(event.payload)}
|
|
422
526
|
FROM projects p WHERE p.org_id = ${orgId} AND p.name = ${event.project}
|
|
423
527
|
`;
|
|
528
|
+
// Realtime backbone: notify listeners (server WS/SSE fan-out, Slack bridge) with the
|
|
529
|
+
// event id only — tiny payload; listeners fetch the full event via getEventById.
|
|
530
|
+
if (result.count > 0) await sql.notify("polaris_event", event.id);
|
|
424
531
|
}
|
|
425
532
|
|
|
426
|
-
function rowToEvent(row: {
|
|
427
|
-
id: string;
|
|
428
|
-
project: string;
|
|
429
|
-
session: string;
|
|
430
|
-
timestamp: Date;
|
|
431
|
-
source: string;
|
|
432
|
-
sender: string;
|
|
433
|
-
payload: unknown;
|
|
434
|
-
}): PolarisEvent {
|
|
533
|
+
function rowToEvent(row: postgres.Row): PolarisEvent {
|
|
435
534
|
return {
|
|
436
535
|
id: row.id,
|
|
437
536
|
project: row.project,
|
|
@@ -443,6 +542,21 @@ function rowToEvent(row: {
|
|
|
443
542
|
};
|
|
444
543
|
}
|
|
445
544
|
|
|
545
|
+
// Lookup for LISTEN('polaris_event') subscribers. Returns org_id too so multi-org
|
|
546
|
+
// consumers (e.g. the Slack bridge) can route without a second query.
|
|
547
|
+
export async function getEventById(sql: Sql, id: string): Promise<(PolarisEvent & { org_id: string }) | null> {
|
|
548
|
+
// NOTIFY payloads are untyped strings; a non-UUID would fail the uuid cast, so treat it as not-found.
|
|
549
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) return null;
|
|
550
|
+
const [row] = await sql`
|
|
551
|
+
SELECT e.id, e.org_id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
|
|
552
|
+
FROM events e
|
|
553
|
+
JOIN projects p ON e.project_id = p.id
|
|
554
|
+
WHERE e.id = ${id}
|
|
555
|
+
`;
|
|
556
|
+
if (!row) return null;
|
|
557
|
+
return { ...rowToEvent(row), org_id: row.org_id };
|
|
558
|
+
}
|
|
559
|
+
|
|
446
560
|
export async function getProjectEvents(sql: Sql, orgId: string, project: string): Promise<PolarisEvent[]> {
|
|
447
561
|
const rows = await sql`
|
|
448
562
|
SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
|
|
@@ -465,6 +579,93 @@ export async function getSessionEvents(sql: Sql, orgId: string, project: string,
|
|
|
465
579
|
return rows.map(rowToEvent);
|
|
466
580
|
}
|
|
467
581
|
|
|
582
|
+
export async function getSessionEventsPage(
|
|
583
|
+
sql: Sql,
|
|
584
|
+
orgId: string,
|
|
585
|
+
project: string,
|
|
586
|
+
session: string,
|
|
587
|
+
opts?: { limit?: number; before?: string }
|
|
588
|
+
): Promise<{ events: PolarisEvent[]; nextCursor: string | null }> {
|
|
589
|
+
const limit = Math.min(Math.max(1, Math.floor(opts?.limit ?? 100)), 500);
|
|
590
|
+
// Cursor is `${iso_timestamp}|${id}`; keyset pagination on (timestamp, id) DESC.
|
|
591
|
+
let beforeTs: string | null = null;
|
|
592
|
+
let beforeId: string | null = null;
|
|
593
|
+
if (opts?.before) {
|
|
594
|
+
const sep = opts.before.lastIndexOf("|");
|
|
595
|
+
if (sep > 0) {
|
|
596
|
+
beforeTs = opts.before.slice(0, sep);
|
|
597
|
+
beforeId = opts.before.slice(sep + 1);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const rows = beforeTs && beforeId
|
|
601
|
+
? await sql`
|
|
602
|
+
SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
|
|
603
|
+
FROM events e
|
|
604
|
+
JOIN projects p ON e.project_id = p.id
|
|
605
|
+
WHERE e.org_id = ${orgId} AND p.name = ${project} AND e.session = ${session}
|
|
606
|
+
AND (e.timestamp, e.id) < (${beforeTs}::timestamptz, ${beforeId}::uuid)
|
|
607
|
+
ORDER BY e.timestamp DESC, e.id DESC
|
|
608
|
+
LIMIT ${limit}
|
|
609
|
+
`
|
|
610
|
+
: await sql`
|
|
611
|
+
SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
|
|
612
|
+
FROM events e
|
|
613
|
+
JOIN projects p ON e.project_id = p.id
|
|
614
|
+
WHERE e.org_id = ${orgId} AND p.name = ${project} AND e.session = ${session}
|
|
615
|
+
ORDER BY e.timestamp DESC, e.id DESC
|
|
616
|
+
LIMIT ${limit}
|
|
617
|
+
`;
|
|
618
|
+
const events = rows.map(rowToEvent);
|
|
619
|
+
const oldest = events[events.length - 1];
|
|
620
|
+
const nextCursor = events.length === limit && oldest ? `${oldest.timestamp}|${oldest.id}` : null;
|
|
621
|
+
return { events, nextCursor };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Query-time full-text search (explicit 'english'::regconfig form, immutable-safe; no index yet).
|
|
625
|
+
// FOLLOW-UP: trigger-maintained indexed search_tsv before scale.
|
|
626
|
+
export async function searchEvents(
|
|
627
|
+
sql: Sql,
|
|
628
|
+
orgId: string,
|
|
629
|
+
opts: { q: string; project?: string; session?: string; sender?: string; source?: string; tag?: string; limit?: number; participantId?: string | null }
|
|
630
|
+
): Promise<{ results: Array<{ event: PolarisEvent; snippet: string }> }> {
|
|
631
|
+
const limit = Math.min(Math.max(1, Math.floor(opts.limit ?? 50)), 100);
|
|
632
|
+
// Restrict to projects the caller may access — without this an unfiltered search
|
|
633
|
+
// would leak 'members'-restricted projects. (no participant -> default-open)
|
|
634
|
+
const aclFilter = opts.participantId
|
|
635
|
+
? sql`AND (COALESCE(p.visibility, 'org') <> 'members' OR EXISTS (
|
|
636
|
+
SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.participant_id = ${opts.participantId}
|
|
637
|
+
))`
|
|
638
|
+
: sql``;
|
|
639
|
+
const rows = await sql`
|
|
640
|
+
SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload,
|
|
641
|
+
ts_headline('english'::regconfig, t.text, q.query) as snippet,
|
|
642
|
+
ts_rank(to_tsvector('english'::regconfig, t.text), q.query) as score
|
|
643
|
+
FROM events e
|
|
644
|
+
JOIN projects p ON e.project_id = p.id
|
|
645
|
+
CROSS JOIN LATERAL (
|
|
646
|
+
SELECT coalesce(e.payload->>'prompt', '') || ' ' || coalesce(e.payload->>'stop_response', '') || ' ' ||
|
|
647
|
+
coalesce(e.payload->>'last_assistant_message', '') || ' ' || coalesce(e.payload->>'content', '') as text
|
|
648
|
+
) t
|
|
649
|
+
CROSS JOIN LATERAL (SELECT websearch_to_tsquery('english', ${opts.q}) as query) q
|
|
650
|
+
WHERE e.org_id = ${orgId}
|
|
651
|
+
AND to_tsvector('english'::regconfig, t.text) @@ q.query
|
|
652
|
+
${opts.project ? sql`AND p.name = ${opts.project}` : sql``}
|
|
653
|
+
${opts.session ? sql`AND e.session = ${opts.session}` : sql``}
|
|
654
|
+
${opts.sender ? sql`AND e.sender = ${opts.sender}` : sql``}
|
|
655
|
+
${opts.source ? sql`AND e.source = ${opts.source}` : sql``}
|
|
656
|
+
${opts.tag ? sql`AND EXISTS (
|
|
657
|
+
SELECT 1 FROM annotations a
|
|
658
|
+
WHERE a.event_id = e.id AND a.org_id = ${orgId} AND a.kind = 'tag' AND a.value = ${opts.tag}
|
|
659
|
+
)` : sql``}
|
|
660
|
+
${aclFilter}
|
|
661
|
+
ORDER BY score DESC, e.timestamp DESC
|
|
662
|
+
LIMIT ${limit}
|
|
663
|
+
`;
|
|
664
|
+
return {
|
|
665
|
+
results: rows.map((row) => ({ event: rowToEvent(row), snippet: row.snippet as string })),
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
468
669
|
export async function getOrgEventsSince(sql: Sql, orgId: string, since: string): Promise<PolarisEvent[]> {
|
|
469
670
|
const rows = await sql`
|
|
470
671
|
SELECT e.id, p.name as project, e.session, e.timestamp, e.source, e.sender, e.payload
|
|
@@ -486,3 +687,77 @@ export async function getEventsSince(sql: Sql, orgId: string, project: string, s
|
|
|
486
687
|
`;
|
|
487
688
|
return rows.map(rowToEvent);
|
|
488
689
|
}
|
|
690
|
+
|
|
691
|
+
// --- Annotations (org-scoped curation: stars, tags, decisions) ---
|
|
692
|
+
|
|
693
|
+
function rowToAnnotation(row: postgres.Row): Annotation {
|
|
694
|
+
return {
|
|
695
|
+
id: row.id,
|
|
696
|
+
event_id: row.event_id ?? null,
|
|
697
|
+
project: row.project,
|
|
698
|
+
session: row.session,
|
|
699
|
+
participant_id: row.participant_id ?? null,
|
|
700
|
+
kind: row.kind as Annotation["kind"],
|
|
701
|
+
value: row.value ?? null,
|
|
702
|
+
created_at: row.created_at.toISOString(),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export async function addAnnotation(
|
|
707
|
+
sql: Sql,
|
|
708
|
+
orgId: string,
|
|
709
|
+
annotation: {
|
|
710
|
+
event_id?: string | null;
|
|
711
|
+
project: string;
|
|
712
|
+
session: string;
|
|
713
|
+
participant_id?: string | null;
|
|
714
|
+
kind: Annotation["kind"];
|
|
715
|
+
value?: string | null;
|
|
716
|
+
}
|
|
717
|
+
): Promise<{ id: string }> {
|
|
718
|
+
const id = crypto.randomUUID();
|
|
719
|
+
await sql`
|
|
720
|
+
INSERT INTO annotations (id, org_id, event_id, project, session, participant_id, kind, value)
|
|
721
|
+
VALUES (${id}, ${orgId}, ${annotation.event_id ?? null}, ${annotation.project}, ${annotation.session},
|
|
722
|
+
${annotation.participant_id ?? null}, ${annotation.kind}, ${annotation.value ?? null})
|
|
723
|
+
`;
|
|
724
|
+
return { id };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export async function listSessionAnnotations(sql: Sql, orgId: string, project: string, session: string): Promise<Annotation[]> {
|
|
728
|
+
const rows = await sql`
|
|
729
|
+
SELECT * FROM annotations
|
|
730
|
+
WHERE org_id = ${orgId} AND project = ${project} AND session = ${session}
|
|
731
|
+
ORDER BY created_at ASC
|
|
732
|
+
`;
|
|
733
|
+
return rows.map(rowToAnnotation);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export async function listDecisions(sql: Sql, orgId: string, opts?: { project?: string; limit?: number; participantId?: string | null }): Promise<Annotation[]> {
|
|
737
|
+
const limit = Math.min(Math.max(1, Math.floor(opts?.limit ?? 100)), 500);
|
|
738
|
+
// Exclude decisions from 'members'-restricted projects the caller isn't in
|
|
739
|
+
// (default-open: missing project row / 'org' visibility / member -> kept).
|
|
740
|
+
const aclFilter = opts?.participantId
|
|
741
|
+
? sql`AND NOT EXISTS (
|
|
742
|
+
SELECT 1 FROM projects p
|
|
743
|
+
WHERE p.org_id = annotations.org_id AND p.name = annotations.project
|
|
744
|
+
AND COALESCE(p.visibility, 'org') = 'members'
|
|
745
|
+
AND NOT EXISTS (
|
|
746
|
+
SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.participant_id = ${opts.participantId}
|
|
747
|
+
)
|
|
748
|
+
)`
|
|
749
|
+
: sql``;
|
|
750
|
+
const rows = await sql`
|
|
751
|
+
SELECT * FROM annotations
|
|
752
|
+
WHERE org_id = ${orgId} AND kind = 'decision'
|
|
753
|
+
${opts?.project ? sql`AND project = ${opts.project}` : sql``}
|
|
754
|
+
${aclFilter}
|
|
755
|
+
ORDER BY created_at DESC
|
|
756
|
+
LIMIT ${limit}
|
|
757
|
+
`;
|
|
758
|
+
return rows.map(rowToAnnotation);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
export async function deleteAnnotation(sql: Sql, orgId: string, id: string): Promise<void> {
|
|
762
|
+
await sql`DELETE FROM annotations WHERE org_id = ${orgId} AND id = ${id}`;
|
|
763
|
+
}
|