@schedulespark/observability 0.0.2-beta-1-0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +384 -0
  4. package/SECURITY_REVIEW.md +90 -0
  5. package/dist/alerts/dispatch.d.ts +6 -0
  6. package/dist/alerts/dispatch.js +11 -0
  7. package/dist/alerts/email.d.ts +37 -0
  8. package/dist/alerts/email.js +29 -0
  9. package/dist/alerts/format.d.ts +12 -0
  10. package/dist/alerts/format.js +30 -0
  11. package/dist/alerts/index.d.ts +8 -0
  12. package/dist/alerts/index.js +4 -0
  13. package/dist/alerts/notifier.d.ts +15 -0
  14. package/dist/alerts/notifier.js +31 -0
  15. package/dist/alerts/spike.d.ts +25 -0
  16. package/dist/alerts/spike.js +41 -0
  17. package/dist/alerts/types.d.ts +38 -0
  18. package/dist/alerts/types.js +1 -0
  19. package/dist/alerts/webhook.d.ts +14 -0
  20. package/dist/alerts/webhook.js +59 -0
  21. package/dist/browser/client.d.ts +26 -0
  22. package/dist/browser/client.js +131 -0
  23. package/dist/browser/index.d.ts +3 -0
  24. package/dist/browser/index.js +1 -0
  25. package/dist/cli/bin.d.ts +2 -0
  26. package/dist/cli/bin.js +6 -0
  27. package/dist/cli/index.d.ts +10 -0
  28. package/dist/cli/index.js +45 -0
  29. package/dist/cli/migrate.d.ts +6 -0
  30. package/dist/cli/migrate.js +14 -0
  31. package/dist/cli/options.d.ts +17 -0
  32. package/dist/cli/options.js +28 -0
  33. package/dist/cli/projects.d.ts +6 -0
  34. package/dist/cli/projects.js +52 -0
  35. package/dist/cli/prune.d.ts +8 -0
  36. package/dist/cli/prune.js +32 -0
  37. package/dist/cli/rollup.d.ts +6 -0
  38. package/dist/cli/rollup.js +24 -0
  39. package/dist/cli/serve.d.ts +5 -0
  40. package/dist/cli/serve.js +72 -0
  41. package/dist/cli/sourcemap.d.ts +8 -0
  42. package/dist/cli/sourcemap.js +113 -0
  43. package/dist/core/breadcrumbs.d.ts +21 -0
  44. package/dist/core/breadcrumbs.js +20 -0
  45. package/dist/core/fingerprint.d.ts +10 -0
  46. package/dist/core/fingerprint.js +35 -0
  47. package/dist/core/index.d.ts +4 -0
  48. package/dist/core/index.js +4 -0
  49. package/dist/core/types.d.ts +219 -0
  50. package/dist/core/types.js +35 -0
  51. package/dist/dashboard/core.d.ts +67 -0
  52. package/dist/dashboard/core.js +40 -0
  53. package/dist/dashboard/fastify.d.ts +17 -0
  54. package/dist/dashboard/fastify.js +247 -0
  55. package/dist/dashboard/html-secondary.d.ts +15 -0
  56. package/dist/dashboard/html-secondary.js +85 -0
  57. package/dist/dashboard/html-shared.d.ts +14 -0
  58. package/dist/dashboard/html-shared.js +39 -0
  59. package/dist/dashboard/html.d.ts +18 -0
  60. package/dist/dashboard/html.js +161 -0
  61. package/dist/dashboard/index.d.ts +5 -0
  62. package/dist/dashboard/index.js +3 -0
  63. package/dist/node/client.d.ts +38 -0
  64. package/dist/node/client.js +191 -0
  65. package/dist/node/exception.d.ts +15 -0
  66. package/dist/node/exception.js +33 -0
  67. package/dist/node/fastify.d.ts +7 -0
  68. package/dist/node/fastify.js +20 -0
  69. package/dist/node/index.d.ts +11 -0
  70. package/dist/node/index.js +5 -0
  71. package/dist/node/logs.d.ts +23 -0
  72. package/dist/node/logs.js +90 -0
  73. package/dist/node/metrics.d.ts +27 -0
  74. package/dist/node/metrics.js +18 -0
  75. package/dist/node/prisma.d.ts +63 -0
  76. package/dist/node/prisma.js +53 -0
  77. package/dist/node/queue.d.ts +25 -0
  78. package/dist/node/queue.js +42 -0
  79. package/dist/node/redact.d.ts +7 -0
  80. package/dist/node/redact.js +26 -0
  81. package/dist/node/tracing.d.ts +28 -0
  82. package/dist/node/tracing.js +49 -0
  83. package/dist/storage/comments.d.ts +13 -0
  84. package/dist/storage/comments.js +20 -0
  85. package/dist/storage/index.d.ts +31 -0
  86. package/dist/storage/index.js +33 -0
  87. package/dist/storage/logs.d.ts +19 -0
  88. package/dist/storage/logs.js +35 -0
  89. package/dist/storage/metrics-ingest.d.ts +14 -0
  90. package/dist/storage/metrics-ingest.js +16 -0
  91. package/dist/storage/metrics-rollup.d.ts +38 -0
  92. package/dist/storage/metrics-rollup.js +80 -0
  93. package/dist/storage/metrics.d.ts +7 -0
  94. package/dist/storage/metrics.js +10 -0
  95. package/dist/storage/migrations.d.ts +17 -0
  96. package/dist/storage/migrations.js +238 -0
  97. package/dist/storage/pool.d.ts +30 -0
  98. package/dist/storage/pool.js +28 -0
  99. package/dist/storage/projects.d.ts +17 -0
  100. package/dist/storage/projects.js +31 -0
  101. package/dist/storage/queries.d.ts +48 -0
  102. package/dist/storage/queries.js +146 -0
  103. package/dist/storage/retention.d.ts +28 -0
  104. package/dist/storage/retention.js +29 -0
  105. package/dist/storage/rows.d.ts +151 -0
  106. package/dist/storage/rows.js +134 -0
  107. package/dist/storage/saved-views.d.ts +15 -0
  108. package/dist/storage/saved-views.js +28 -0
  109. package/dist/storage/schema-ident.d.ts +7 -0
  110. package/dist/storage/schema-ident.js +13 -0
  111. package/dist/storage/spans.d.ts +14 -0
  112. package/dist/storage/spans.js +21 -0
  113. package/docs/screenshots/dashboard.jpg +0 -0
  114. package/docs/standalone-example.md +110 -0
  115. package/package.json +102 -0
@@ -0,0 +1,238 @@
1
+ export /**
2
+ * All versioned migrations, applied in order.
3
+ */ const migrations = [
4
+ {
5
+ id: "0001_init",
6
+ sql: (schema) => `
7
+ CREATE TABLE IF NOT EXISTS ${schema}.issues (
8
+ id text PRIMARY KEY,
9
+ fingerprint text NOT NULL UNIQUE,
10
+ title text NOT NULL,
11
+ level text NOT NULL,
12
+ status text NOT NULL DEFAULT 'unresolved',
13
+ event_count integer NOT NULL DEFAULT 0,
14
+ first_seen timestamptz NOT NULL,
15
+ last_seen timestamptz NOT NULL
16
+ );
17
+
18
+ CREATE TABLE IF NOT EXISTS ${schema}.events (
19
+ id text PRIMARY KEY,
20
+ issue_id text NOT NULL REFERENCES ${schema}.issues(id) ON DELETE CASCADE,
21
+ fingerprint text NOT NULL,
22
+ level text NOT NULL,
23
+ message text NOT NULL,
24
+ error_type text,
25
+ stack_trace text,
26
+ context jsonb NOT NULL DEFAULT '{}'::jsonb,
27
+ captured_at timestamptz NOT NULL
28
+ );
29
+
30
+ CREATE INDEX IF NOT EXISTS events_issue_id_idx ON ${schema}.events (issue_id);
31
+ CREATE INDEX IF NOT EXISTS events_captured_at_idx ON ${schema}.events (captured_at DESC);
32
+ CREATE INDEX IF NOT EXISTS issues_last_seen_idx ON ${schema}.issues (last_seen DESC);
33
+ `
34
+ },
35
+ {
36
+ id: "0002_assignee_comments",
37
+ sql: (schema) => `
38
+ ALTER TABLE ${schema}.issues ADD COLUMN IF NOT EXISTS assignee text;
39
+
40
+ CREATE TABLE IF NOT EXISTS ${schema}.comments (
41
+ id text PRIMARY KEY,
42
+ issue_id text NOT NULL REFERENCES ${schema}.issues(id) ON DELETE CASCADE,
43
+ author text NOT NULL,
44
+ body text NOT NULL,
45
+ created_at timestamptz NOT NULL
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS comments_issue_id_idx ON ${schema}.comments (issue_id);
49
+ `
50
+ },
51
+ {
52
+ id: "0003_spans",
53
+ sql: (schema) => `
54
+ CREATE TABLE IF NOT EXISTS ${schema}.spans (
55
+ id text PRIMARY KEY,
56
+ trace_id text NOT NULL,
57
+ parent_id text,
58
+ name text NOT NULL,
59
+ status text NOT NULL,
60
+ started_at timestamptz NOT NULL,
61
+ duration_ms integer NOT NULL,
62
+ tags jsonb NOT NULL DEFAULT '{}'::jsonb
63
+ );
64
+
65
+ CREATE INDEX IF NOT EXISTS spans_trace_id_idx ON ${schema}.spans (trace_id);
66
+ CREATE INDEX IF NOT EXISTS spans_transactions_idx ON ${schema}.spans (started_at DESC)
67
+ WHERE parent_id IS NULL;
68
+ `
69
+ },
70
+ {
71
+ id: "0004_projects",
72
+ sql: (schema) => `
73
+ CREATE TABLE IF NOT EXISTS ${schema}.projects (
74
+ id text PRIMARY KEY,
75
+ name text NOT NULL,
76
+ api_key text UNIQUE,
77
+ created_at timestamptz NOT NULL DEFAULT now()
78
+ );
79
+
80
+ INSERT INTO ${schema}.projects (id, name, api_key, created_at)
81
+ VALUES ('default', 'Default', NULL, now())
82
+ ON CONFLICT (id) DO NOTHING;
83
+
84
+ ALTER TABLE ${schema}.issues
85
+ ADD COLUMN IF NOT EXISTS project_id text NOT NULL DEFAULT 'default' REFERENCES ${schema}.projects(id);
86
+ ALTER TABLE ${schema}.events
87
+ ADD COLUMN IF NOT EXISTS project_id text NOT NULL DEFAULT 'default' REFERENCES ${schema}.projects(id);
88
+
89
+ -- Fingerprints are now unique per-project, not globally.
90
+ ALTER TABLE ${schema}.issues DROP CONSTRAINT IF EXISTS issues_fingerprint_key;
91
+ CREATE UNIQUE INDEX IF NOT EXISTS issues_project_fingerprint_idx
92
+ ON ${schema}.issues (project_id, fingerprint);
93
+
94
+ CREATE INDEX IF NOT EXISTS issues_project_id_idx ON ${schema}.issues (project_id);
95
+ CREATE INDEX IF NOT EXISTS events_project_id_idx ON ${schema}.events (project_id);
96
+ `
97
+ },
98
+ {
99
+ id: "0005_breadcrumbs",
100
+ sql: (schema) => `
101
+ ALTER TABLE ${schema}.events
102
+ ADD COLUMN IF NOT EXISTS breadcrumbs jsonb NOT NULL DEFAULT '[]'::jsonb;
103
+ `
104
+ },
105
+ {
106
+ id: "0006_saved_views",
107
+ sql: (schema) => `
108
+ CREATE TABLE IF NOT EXISTS ${schema}.saved_views (
109
+ id text PRIMARY KEY,
110
+ name text NOT NULL,
111
+ filters jsonb NOT NULL DEFAULT '{}'::jsonb,
112
+ created_at timestamptz NOT NULL DEFAULT now()
113
+ );
114
+ `
115
+ },
116
+ {
117
+ id: "0007_logs",
118
+ sql: (schema) => `
119
+ CREATE TABLE IF NOT EXISTS ${schema}.logs (
120
+ id text PRIMARY KEY,
121
+ project_id text NOT NULL DEFAULT 'default' REFERENCES ${schema}.projects(id),
122
+ level text NOT NULL,
123
+ message text NOT NULL,
124
+ context jsonb NOT NULL DEFAULT '{}'::jsonb,
125
+ logged_at timestamptz NOT NULL
126
+ );
127
+
128
+ CREATE INDEX IF NOT EXISTS logs_logged_at_idx ON ${schema}.logs (logged_at DESC);
129
+ CREATE INDEX IF NOT EXISTS logs_project_id_idx ON ${schema}.logs (project_id);
130
+ `
131
+ },
132
+ {
133
+ id: "0008_metric_points_raw",
134
+ sql: (schema) => `
135
+ CREATE TABLE IF NOT EXISTS ${schema}.metric_points_raw (
136
+ id text PRIMARY KEY,
137
+ project_id text NOT NULL DEFAULT 'default' REFERENCES ${schema}.projects(id),
138
+ name text NOT NULL,
139
+ kind text NOT NULL,
140
+ value double precision NOT NULL,
141
+ tags jsonb NOT NULL DEFAULT '{}'::jsonb,
142
+ recorded_at timestamptz NOT NULL
143
+ );
144
+
145
+ CREATE INDEX IF NOT EXISTS metric_points_raw_recorded_at_idx ON ${schema}.metric_points_raw (recorded_at DESC);
146
+ CREATE INDEX IF NOT EXISTS metric_points_raw_name_idx ON ${schema}.metric_points_raw (project_id, name);
147
+ `
148
+ },
149
+ {
150
+ id: "0009_metric_rollups",
151
+ sql: (schema) => `
152
+ CREATE TABLE IF NOT EXISTS ${schema}.metric_rollups (
153
+ id text PRIMARY KEY,
154
+ project_id text NOT NULL DEFAULT 'default' REFERENCES ${schema}.projects(id),
155
+ name text NOT NULL,
156
+ kind text NOT NULL,
157
+ bucket_start timestamptz NOT NULL,
158
+ bucket_size text NOT NULL,
159
+ tags jsonb NOT NULL DEFAULT '{}'::jsonb,
160
+ sum double precision NOT NULL,
161
+ count integer NOT NULL,
162
+ min double precision NOT NULL,
163
+ max double precision NOT NULL
164
+ );
165
+
166
+ -- jsonb equality identifies "this metric/tag-combination/bucket" for the rollup upsert.
167
+ CREATE UNIQUE INDEX IF NOT EXISTS metric_rollups_unique_bucket_idx
168
+ ON ${schema}.metric_rollups (project_id, name, tags, bucket_start, bucket_size);
169
+ CREATE INDEX IF NOT EXISTS metric_rollups_name_idx ON ${schema}.metric_rollups (project_id, name);
170
+ `
171
+ }
172
+ ];
173
+ /**
174
+ * Ensures the observability schema, migration-tracking table, and all pending
175
+ * migrations exist. Safe to call concurrently from multiple pools/processes against
176
+ * the same database (e.g. a Node SDK client and a dashboard server booting at the same
177
+ * time): a Postgres advisory lock, scoped to the schema name, serializes migration
178
+ * runs so `CREATE SCHEMA IF NOT EXISTS` races can never produce a duplicate-key error.
179
+ */
180
+ export async function migrate(pool, quotedSchema) {
181
+ const client = await pool.connect();
182
+ try {
183
+ await withAdvisoryLock(client, quotedSchema, () => runMigrations(client, quotedSchema));
184
+ }
185
+ finally {
186
+ client.release();
187
+ }
188
+ }
189
+ /**
190
+ * Runs a function while holding a session-scoped Postgres advisory lock keyed by the
191
+ * schema name, releasing it afterward even if the function throws.
192
+ */
193
+ async function withAdvisoryLock(client, quotedSchema, run) {
194
+ await client.query("SELECT pg_advisory_lock(hashtext($1)::bigint)", [quotedSchema]);
195
+ try {
196
+ await run();
197
+ }
198
+ finally {
199
+ await client.query("SELECT pg_advisory_unlock(hashtext($1)::bigint)", [quotedSchema]);
200
+ }
201
+ }
202
+ /**
203
+ * Creates the schema and migration-tracking table if needed, then applies any
204
+ * migrations that haven't been recorded yet.
205
+ */
206
+ async function runMigrations(client, quotedSchema) {
207
+ await client.query(`CREATE SCHEMA IF NOT EXISTS ${quotedSchema}`);
208
+ await client.query(`
209
+ CREATE TABLE IF NOT EXISTS ${quotedSchema}._migrations (
210
+ id text PRIMARY KEY,
211
+ applied_at timestamptz NOT NULL DEFAULT now()
212
+ )
213
+ `);
214
+ for (const migration of migrations) {
215
+ await applyIfPending(client, quotedSchema, migration);
216
+ }
217
+ }
218
+ /**
219
+ * Applies a single migration inside a transaction if it hasn't been recorded yet.
220
+ */
221
+ async function applyIfPending(client, quotedSchema, migration) {
222
+ const { rows } = await client.query(`SELECT id FROM ${quotedSchema}._migrations WHERE id = $1`, [migration.id]);
223
+ if (rows.length > 0) {
224
+ return;
225
+ }
226
+ try {
227
+ await client.query("BEGIN");
228
+ await client.query(migration.sql(quotedSchema));
229
+ await client.query(`INSERT INTO ${quotedSchema}._migrations (id) VALUES ($1)`, [
230
+ migration.id
231
+ ]);
232
+ await client.query("COMMIT");
233
+ }
234
+ catch (error) {
235
+ await client.query("ROLLBACK");
236
+ throw error;
237
+ }
238
+ }
@@ -0,0 +1,30 @@
1
+ import { Pool } from "pg";
2
+ /**
3
+ * Options for connecting the observability storage layer to a Postgres database.
4
+ */
5
+ export interface StorageOptions {
6
+ connectionString: string;
7
+ schema?: string;
8
+ }
9
+ /**
10
+ * A connected storage handle: the underlying pool, the quoted schema identifier every
11
+ * query is scoped to, and a `ready` promise that resolves once migrations have been
12
+ * applied (or rejects if they failed). Every query function awaits `ready` first, so
13
+ * it's always safe to query a handle immediately after `createStorage()` returns.
14
+ */
15
+ export interface StorageHandle {
16
+ pool: Pool;
17
+ schema: string;
18
+ quotedSchema: string;
19
+ ready: Promise<void>;
20
+ }
21
+ /**
22
+ * Opens a connection pool for the observability database, scoped to its own schema so
23
+ * it can coexist safely inside a host application's existing database, and starts
24
+ * migrating that schema in the background (never blocking the caller).
25
+ */
26
+ export declare function createStorage(options: StorageOptions): StorageHandle;
27
+ /**
28
+ * Closes the underlying connection pool. Safe to call during graceful shutdown.
29
+ */
30
+ export declare function closeStorage(handle: StorageHandle): Promise<void>;
@@ -0,0 +1,28 @@
1
+ import { Pool } from "pg";
2
+ import { migrate } from "./migrations.js";
3
+ import { quoteSchemaIdentifier } from "./schema-ident.js";
4
+ const DEFAULT_SCHEMA = "observability";
5
+ /**
6
+ * Opens a connection pool for the observability database, scoped to its own schema so
7
+ * it can coexist safely inside a host application's existing database, and starts
8
+ * migrating that schema in the background (never blocking the caller).
9
+ */
10
+ export function createStorage(options) {
11
+ const schema = options.schema ?? DEFAULT_SCHEMA;
12
+ const quotedSchema = quoteSchemaIdentifier(schema);
13
+ const pool = new Pool({ connectionString: options.connectionString });
14
+ pool.on("error", (error) => {
15
+ console.error("[observability] idle Postgres client error", error);
16
+ });
17
+ const ready = migrate(pool, quotedSchema);
18
+ // Mark the rejection as handled so Node doesn't warn/crash if a caller never
19
+ // awaits `ready` themselves; the real rejection is still observable through it.
20
+ ready.catch(() => undefined);
21
+ return { pool, schema, quotedSchema, ready };
22
+ }
23
+ /**
24
+ * Closes the underlying connection pool. Safe to call during graceful shutdown.
25
+ */
26
+ export async function closeStorage(handle) {
27
+ await handle.pool.end();
28
+ }
@@ -0,0 +1,17 @@
1
+ import type { StorageHandle } from "./pool.js";
2
+ import type { Project } from "../core/index.js";
3
+ /**
4
+ * Creates a new project with a freshly generated API key, used by ingestion clients
5
+ * to identify which project an event belongs to.
6
+ */
7
+ export declare function createProject(handle: StorageHandle, name: string): Promise<Project>;
8
+ /**
9
+ * Lists all projects, oldest first (so the auto-created `default` project sorts
10
+ * first).
11
+ */
12
+ export declare function listProjects(handle: StorageHandle): Promise<Project[]>;
13
+ /**
14
+ * Looks up the project an ingestion API key belongs to, or `null` if it doesn't match
15
+ * any configured project.
16
+ */
17
+ export declare function findProjectByApiKey(handle: StorageHandle, apiKey: string): Promise<Project | null>;
@@ -0,0 +1,31 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { mapProjectRow } from "./rows.js";
3
+ const API_KEY_BYTES = 24;
4
+ /**
5
+ * Creates a new project with a freshly generated API key, used by ingestion clients
6
+ * to identify which project an event belongs to.
7
+ */
8
+ export async function createProject(handle, name) {
9
+ await handle.ready;
10
+ const { rows } = await handle.pool.query(`INSERT INTO ${handle.quotedSchema}.projects (id, name, api_key, created_at)
11
+ VALUES ($1, $2, $3, now()) RETURNING *`, [randomUUID(), name, randomBytes(API_KEY_BYTES).toString("hex")]);
12
+ return mapProjectRow(rows[0]);
13
+ }
14
+ /**
15
+ * Lists all projects, oldest first (so the auto-created `default` project sorts
16
+ * first).
17
+ */
18
+ export async function listProjects(handle) {
19
+ await handle.ready;
20
+ const { rows } = await handle.pool.query(`SELECT * FROM ${handle.quotedSchema}.projects ORDER BY created_at ASC`);
21
+ return rows.map(mapProjectRow);
22
+ }
23
+ /**
24
+ * Looks up the project an ingestion API key belongs to, or `null` if it doesn't match
25
+ * any configured project.
26
+ */
27
+ export async function findProjectByApiKey(handle, apiKey) {
28
+ await handle.ready;
29
+ const { rows } = await handle.pool.query(`SELECT * FROM ${handle.quotedSchema}.projects WHERE api_key = $1`, [apiKey]);
30
+ return rows.length > 0 ? mapProjectRow(rows[0]) : null;
31
+ }
@@ -0,0 +1,48 @@
1
+ import type { StorageHandle } from "./pool.js";
2
+ import type { CapturedEvent, CaptureInput, Comment, Issue, IssueStatus } from "../core/index.js";
3
+ /**
4
+ * Result of recording a single captured event.
5
+ */
6
+ export interface RecordEventResult {
7
+ event: CapturedEvent;
8
+ issue: Issue;
9
+ isNewIssue: boolean;
10
+ isRegression: boolean;
11
+ }
12
+ /**
13
+ * Records a captured event: upserts the owning issue (creating it on first
14
+ * occurrence, bumping its counters otherwise) and inserts the event row, all inside a
15
+ * single transaction so the two stay consistent. Events default to the `"default"`
16
+ * project when the caller doesn't configure multi-project support, so this is a
17
+ * purely additive parameter.
18
+ */
19
+ export declare function recordEvent(handle: StorageHandle, input: CaptureInput, projectId?: string): Promise<RecordEventResult>;
20
+ /**
21
+ * Lists issues ordered by most recently seen, optionally filtered by status and/or a
22
+ * case-insensitive substring match on the title.
23
+ */
24
+ export declare function listIssues(handle: StorageHandle, options?: {
25
+ status?: IssueStatus;
26
+ q?: string;
27
+ limit?: number;
28
+ projectId?: string;
29
+ }): Promise<Issue[]>;
30
+ /**
31
+ * Fetches a single issue, its most recent events, and its comments, or `null` if it
32
+ * doesn't exist (or belongs to a different project than `projectId`, when given).
33
+ */
34
+ export declare function getIssueWithEvents(handle: StorageHandle, issueId: string, projectId?: string): Promise<{
35
+ issue: Issue;
36
+ events: CapturedEvent[];
37
+ comments: Comment[];
38
+ } | null>;
39
+ /**
40
+ * Updates an issue's workflow status, returning the updated issue or `null` if it
41
+ * doesn't exist.
42
+ */
43
+ export declare function updateIssueStatus(handle: StorageHandle, issueId: string, status: IssueStatus): Promise<Issue | null>;
44
+ /**
45
+ * Assigns (or unassigns, with `null`) an issue, returning the updated issue or `null`
46
+ * if it doesn't exist.
47
+ */
48
+ export declare function assignIssue(handle: StorageHandle, issueId: string, assignee: string | null): Promise<Issue | null>;
@@ -0,0 +1,146 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { computeFingerprint } from "../core/fingerprint.js";
3
+ import { DEFAULT_PROJECT_ID } from "../core/index.js";
4
+ import { listComments } from "./comments.js";
5
+ import { deriveTitle, mapEventRow, mapIssueRow } from "./rows.js";
6
+ const DEFAULT_ISSUE_LIST_LIMIT = 100;
7
+ const DEFAULT_EVENT_LIST_LIMIT = 50;
8
+ /**
9
+ * Records a captured event: upserts the owning issue (creating it on first
10
+ * occurrence, bumping its counters otherwise) and inserts the event row, all inside a
11
+ * single transaction so the two stay consistent. Events default to the `"default"`
12
+ * project when the caller doesn't configure multi-project support, so this is a
13
+ * purely additive parameter.
14
+ */
15
+ export async function recordEvent(handle, input, projectId = DEFAULT_PROJECT_ID) {
16
+ await handle.ready;
17
+ const data = { input, fingerprint: computeFingerprint(input), now: new Date(), projectId };
18
+ const client = await handle.pool.connect();
19
+ try {
20
+ await client.query("BEGIN");
21
+ const issueRow = await upsertIssue(client, handle.quotedSchema, data);
22
+ const eventRow = await insertEvent(client, handle.quotedSchema, data);
23
+ await client.query("COMMIT");
24
+ return {
25
+ event: mapEventRow(eventRow),
26
+ issue: mapIssueRow(issueRow),
27
+ isNewIssue: issueRow.event_count === 1,
28
+ isRegression: issueRow.event_count > 1 && issueRow.previous_status === "resolved"
29
+ };
30
+ }
31
+ catch (error) {
32
+ await client.query("ROLLBACK");
33
+ throw error;
34
+ }
35
+ finally {
36
+ client.release();
37
+ }
38
+ }
39
+ /**
40
+ * Inserts a new issue row for a fingerprint or, if one already exists, bumps its
41
+ * event count and last-seen timestamp. A fresh event on a previously `resolved`
42
+ * issue automatically reopens it (back to `unresolved`) — `ignored` issues are left
43
+ * alone, since that status is a deliberate "don't tell me about this" choice rather
44
+ * than "this is fixed." The pre-update status is returned as `previous_status` so the
45
+ * caller can detect that reopen and fire a distinct regression alert.
46
+ */
47
+ async function upsertIssue(client, quotedSchema, data) {
48
+ const { rows } = await client.query(`WITH previous AS (
49
+ SELECT status FROM ${quotedSchema}.issues WHERE project_id = $2 AND fingerprint = $3
50
+ )
51
+ INSERT INTO ${quotedSchema}.issues
52
+ (id, project_id, fingerprint, title, level, status, event_count, first_seen, last_seen)
53
+ VALUES ($1, $2, $3, $4, $5, 'unresolved', 1, $6, $6)
54
+ ON CONFLICT (project_id, fingerprint) DO UPDATE SET
55
+ event_count = ${quotedSchema}.issues.event_count + 1,
56
+ last_seen = $6,
57
+ status = CASE
58
+ WHEN ${quotedSchema}.issues.status = 'resolved' THEN 'unresolved'
59
+ ELSE ${quotedSchema}.issues.status
60
+ END
61
+ RETURNING ${quotedSchema}.issues.*, (SELECT status FROM previous) AS previous_status`, [randomUUID(), data.projectId, data.fingerprint, deriveTitle(data.input), data.input.level, data.now]);
62
+ return rows[0];
63
+ }
64
+ /**
65
+ * Inserts a single event row, linked to its issue by fingerprint.
66
+ */
67
+ async function insertEvent(client, quotedSchema, data) {
68
+ const { rows } = await client.query(`INSERT INTO ${quotedSchema}.events
69
+ (id, issue_id, project_id, fingerprint, level, message, error_type, stack_trace, context, breadcrumbs, captured_at)
70
+ VALUES (
71
+ $1,
72
+ (SELECT id FROM ${quotedSchema}.issues WHERE project_id = $2 AND fingerprint = $3),
73
+ $2, $3, $4, $5, $6, $7, $8, $9, $10
74
+ )
75
+ RETURNING *`, [
76
+ randomUUID(),
77
+ data.projectId,
78
+ data.fingerprint,
79
+ data.input.level,
80
+ data.input.message,
81
+ data.input.errorType ?? null,
82
+ data.input.stackTrace ?? null,
83
+ data.input.context ?? {},
84
+ JSON.stringify(data.input.breadcrumbs ?? []),
85
+ data.now
86
+ ]);
87
+ return rows[0];
88
+ }
89
+ /**
90
+ * Lists issues ordered by most recently seen, optionally filtered by status and/or a
91
+ * case-insensitive substring match on the title.
92
+ */
93
+ export async function listIssues(handle, options = {}) {
94
+ await handle.ready;
95
+ const params = [];
96
+ const conditions = [];
97
+ if (options.projectId) {
98
+ params.push(options.projectId);
99
+ conditions.push(`project_id = $${String(params.length)}`);
100
+ }
101
+ if (options.status) {
102
+ params.push(options.status);
103
+ conditions.push(`status = $${String(params.length)}`);
104
+ }
105
+ if (options.q) {
106
+ params.push(`%${options.q}%`);
107
+ conditions.push(`title ILIKE $${String(params.length)}`);
108
+ }
109
+ params.push(options.limit ?? DEFAULT_ISSUE_LIST_LIMIT);
110
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
111
+ const { rows } = await handle.pool.query(`SELECT * FROM ${handle.quotedSchema}.issues ${where} ORDER BY last_seen DESC LIMIT $${String(params.length)}`, params);
112
+ return rows.map(mapIssueRow);
113
+ }
114
+ /**
115
+ * Fetches a single issue, its most recent events, and its comments, or `null` if it
116
+ * doesn't exist (or belongs to a different project than `projectId`, when given).
117
+ */
118
+ export async function getIssueWithEvents(handle, issueId, projectId) {
119
+ await handle.ready;
120
+ const params = projectId ? [issueId, projectId] : [issueId];
121
+ const { rows: issueRows } = await handle.pool.query(`SELECT * FROM ${handle.quotedSchema}.issues WHERE id = $1 ${projectId ? "AND project_id = $2" : ""}`, params);
122
+ if (issueRows.length === 0) {
123
+ return null;
124
+ }
125
+ const { rows: eventRows } = await handle.pool.query(`SELECT * FROM ${handle.quotedSchema}.events WHERE issue_id = $1 ORDER BY captured_at DESC LIMIT $2`, [issueId, DEFAULT_EVENT_LIST_LIMIT]);
126
+ const comments = await listComments(handle, issueId);
127
+ return { issue: mapIssueRow(issueRows[0]), events: eventRows.map(mapEventRow), comments };
128
+ }
129
+ /**
130
+ * Updates an issue's workflow status, returning the updated issue or `null` if it
131
+ * doesn't exist.
132
+ */
133
+ export async function updateIssueStatus(handle, issueId, status) {
134
+ await handle.ready;
135
+ const { rows } = await handle.pool.query(`UPDATE ${handle.quotedSchema}.issues SET status = $2 WHERE id = $1 RETURNING *`, [issueId, status]);
136
+ return rows.length > 0 ? mapIssueRow(rows[0]) : null;
137
+ }
138
+ /**
139
+ * Assigns (or unassigns, with `null`) an issue, returning the updated issue or `null`
140
+ * if it doesn't exist.
141
+ */
142
+ export async function assignIssue(handle, issueId, assignee) {
143
+ await handle.ready;
144
+ const { rows } = await handle.pool.query(`UPDATE ${handle.quotedSchema}.issues SET assignee = $2 WHERE id = $1 RETURNING *`, [issueId, assignee]);
145
+ return rows.length > 0 ? mapIssueRow(rows[0]) : null;
146
+ }
@@ -0,0 +1,28 @@
1
+ import type { StorageHandle } from "./pool.js";
2
+ /**
3
+ * Result of a prune operation.
4
+ */
5
+ export interface PruneResult {
6
+ deletedEvents: number;
7
+ }
8
+ /**
9
+ * Deletes events older than the given retention window, keeping their owning issues
10
+ * (and aggregate counts) intact — only the individual event detail rows (stack
11
+ * traces, context) are dropped. A stopgap against unbounded table growth ahead of
12
+ * proper rollup tables: since this writes into the caller's own database, nothing
13
+ * prunes it automatically unless the deployer schedules this themselves (CLI `prune`
14
+ * or a direct call).
15
+ */
16
+ export declare function pruneEvents(handle: StorageHandle, olderThanDays: number): Promise<PruneResult>;
17
+ /**
18
+ * Result of pruning old log lines.
19
+ */
20
+ export interface PruneLogsResult {
21
+ deletedLogs: number;
22
+ }
23
+ /**
24
+ * Deletes log lines older than the given retention window. Logs are typically much
25
+ * higher-volume than events, so this is a separate operation with its own (usually
26
+ * shorter) retention window rather than folding into `pruneEvents`.
27
+ */
28
+ export declare function pruneLogs(handle: StorageHandle, olderThanDays: number): Promise<PruneLogsResult>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Deletes events older than the given retention window, keeping their owning issues
3
+ * (and aggregate counts) intact — only the individual event detail rows (stack
4
+ * traces, context) are dropped. A stopgap against unbounded table growth ahead of
5
+ * proper rollup tables: since this writes into the caller's own database, nothing
6
+ * prunes it automatically unless the deployer schedules this themselves (CLI `prune`
7
+ * or a direct call).
8
+ */
9
+ export async function pruneEvents(handle, olderThanDays) {
10
+ if (!Number.isInteger(olderThanDays) || olderThanDays <= 0) {
11
+ throw new Error(`olderThanDays must be a positive integer, got ${String(olderThanDays)}`);
12
+ }
13
+ await handle.ready;
14
+ const { rowCount } = await handle.pool.query(`DELETE FROM ${handle.quotedSchema}.events WHERE captured_at < now() - ($1 || ' days')::interval`, [olderThanDays]);
15
+ return { deletedEvents: rowCount ?? 0 };
16
+ }
17
+ /**
18
+ * Deletes log lines older than the given retention window. Logs are typically much
19
+ * higher-volume than events, so this is a separate operation with its own (usually
20
+ * shorter) retention window rather than folding into `pruneEvents`.
21
+ */
22
+ export async function pruneLogs(handle, olderThanDays) {
23
+ if (!Number.isInteger(olderThanDays) || olderThanDays <= 0) {
24
+ throw new Error(`olderThanDays must be a positive integer, got ${String(olderThanDays)}`);
25
+ }
26
+ await handle.ready;
27
+ const { rowCount } = await handle.pool.query(`DELETE FROM ${handle.quotedSchema}.logs WHERE logged_at < now() - ($1 || ' days')::interval`, [olderThanDays]);
28
+ return { deletedLogs: rowCount ?? 0 };
29
+ }