@triagepilot/db 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +105 -0
  2. package/dist/availability.d.ts +164 -0
  3. package/dist/availability.js +974 -0
  4. package/dist/database.d.ts +3 -0
  5. package/dist/database.js +9 -0
  6. package/dist/decisions.d.ts +107 -0
  7. package/dist/decisions.js +435 -0
  8. package/dist/deliveries.d.ts +30 -0
  9. package/dist/deliveries.js +82 -0
  10. package/dist/heartbeat.d.ts +11 -0
  11. package/dist/heartbeat.js +11 -0
  12. package/dist/index.d.ts +14 -0
  13. package/dist/index.js +13 -0
  14. package/dist/jobs.d.ts +86 -0
  15. package/dist/jobs.js +641 -0
  16. package/dist/kysely.d.ts +202 -0
  17. package/dist/kysely.js +1 -0
  18. package/dist/migrate.d.ts +1 -0
  19. package/dist/migrate.js +45 -0
  20. package/dist/operations.d.ts +77 -0
  21. package/dist/operations.js +256 -0
  22. package/dist/outbox.d.ts +61 -0
  23. package/dist/outbox.js +230 -0
  24. package/dist/provider-connections.d.ts +34 -0
  25. package/dist/provider-connections.js +302 -0
  26. package/dist/retention.d.ts +6 -0
  27. package/dist/retention.js +44 -0
  28. package/dist/routing-recovery.d.ts +55 -0
  29. package/dist/routing-recovery.js +175 -0
  30. package/dist/workspaces.d.ts +50 -0
  31. package/dist/workspaces.js +43 -0
  32. package/migrations/0001_initial.sql +79 -0
  33. package/migrations/0002_selected_reviewers.sql +10 -0
  34. package/migrations/0003_human_review_policy.sql +10 -0
  35. package/migrations/0004_semantic_routing_deduplication.sql +15 -0
  36. package/migrations/0005_reviewer_availability.sql +58 -0
  37. package/migrations/0005_workspace_scope.sql +198 -0
  38. package/migrations/0006_decision_outbox.sql +24 -0
  39. package/migrations/0007_workspace_reviewer_availability.sql +192 -0
  40. package/migrations/0008_reviewer_mutation_intents.sql +160 -0
  41. package/migrations/0009_provider_connection_revocations.sql +18 -0
  42. package/migrations/0010_provider_connection_preemptive_revocations.sql +12 -0
  43. package/package.json +42 -0
@@ -0,0 +1,202 @@
1
+ import type { ColumnType, Generated, Insertable, Selectable, Updateable } from "kysely";
2
+ import type { ActionStatus, PlatformEventV1, ProviderKind, RepositoryMode, ReviewerReplacementOutcome } from "@triagepilot/contracts";
3
+ type Timestamp = ColumnType<Date, Date | string | undefined, Date | string>;
4
+ type NullableTimestamp = ColumnType<Date | null, Date | string | null | undefined, Date | string | null>;
5
+ type Json = ColumnType<unknown, unknown, unknown>;
6
+ type NullableBigInt = ColumnType<string | null, string | null, string | null>;
7
+ type NullableString = ColumnType<string | null, string | null | undefined, string | null>;
8
+ type HumanReviewPolicyCheckState = "not_started" | "in_progress" | "success" | "failure";
9
+ type InheritanceMode = "legacy" | "defaults" | "organization" | "replace" | "inherit";
10
+ export interface WorkspacesTable {
11
+ id: Generated<string>;
12
+ external_key: string;
13
+ created_at: Timestamp;
14
+ }
15
+ export interface ProviderConnectionsTable {
16
+ id: Generated<string>;
17
+ workspace_id: string;
18
+ provider: ProviderKind;
19
+ external_connection_id: string;
20
+ workspace_login: string;
21
+ account_type: string;
22
+ status: "active" | "suspended" | "revoked";
23
+ permissions: Json;
24
+ created_at: Timestamp;
25
+ updated_at: Timestamp;
26
+ }
27
+ export interface ProviderConnectionRevocationsTable {
28
+ workspace_id: string;
29
+ provider: ProviderKind;
30
+ external_connection_id: string;
31
+ revoked_connection_id: Generated<string>;
32
+ physical_connection_id: string | null;
33
+ revoked_at: Timestamp;
34
+ cleanup_completed_at: NullableTimestamp;
35
+ }
36
+ export interface RepositoriesTable {
37
+ id: Generated<string>;
38
+ workspace_id: string;
39
+ provider: ProviderKind;
40
+ provider_connection_id: string;
41
+ external_repository_id: string;
42
+ owner: string;
43
+ name: string;
44
+ default_branch: string | null;
45
+ config_state: string;
46
+ last_config_mode: Generated<RepositoryMode>;
47
+ created_at: Timestamp;
48
+ updated_at: Timestamp;
49
+ }
50
+ export interface WebhookReceiptsTable {
51
+ id: Generated<string>;
52
+ workspace_id: string;
53
+ provider: ProviderKind;
54
+ delivery_id: string;
55
+ event_name: string;
56
+ event_action: string | null;
57
+ hook_id: string | null;
58
+ external_connection_id: string | null;
59
+ payload_summary: Json;
60
+ created_at: Timestamp;
61
+ }
62
+ export interface JobsTable {
63
+ id: Generated<string>;
64
+ workspace_id: string;
65
+ provider: ProviderKind;
66
+ provider_connection_id: string;
67
+ kind: string;
68
+ status: Generated<"queued" | "running" | "succeeded" | "failed">;
69
+ payload: Json;
70
+ idempotency_key: string;
71
+ attempt_count: Generated<number>;
72
+ max_attempts: Generated<number>;
73
+ run_at: Timestamp;
74
+ locked_at: NullableTimestamp;
75
+ locked_by: string | null;
76
+ last_error: string | null;
77
+ created_at: Timestamp;
78
+ updated_at: Timestamp;
79
+ }
80
+ export interface RoutingDecisionsTable {
81
+ id: Generated<string>;
82
+ workspace_id: string;
83
+ repository_id: string | null;
84
+ delivery_id: string;
85
+ routing_key: string;
86
+ action: string;
87
+ risk_score: number;
88
+ selected_reviewer: string | null;
89
+ selected_reviewers: ColumnType<unknown, unknown | undefined, unknown>;
90
+ no_human_reason: string | null;
91
+ pull_number: number | null;
92
+ change_request_id: string | null;
93
+ head_sha: string | null;
94
+ policy_check_run_id: NullableBigInt;
95
+ policy_check_state: Generated<HumanReviewPolicyCheckState>;
96
+ details: Json;
97
+ mode: Generated<RepositoryMode>;
98
+ action_status: Generated<ActionStatus>;
99
+ action_error: string | null;
100
+ action_applied_at: NullableTimestamp;
101
+ action_failed_at: NullableTimestamp;
102
+ organization_config_version: string | null;
103
+ repository_config_path: string | null;
104
+ repository_config_revision: string | null;
105
+ effective_config_hash: string;
106
+ inheritance_mode: InheritanceMode;
107
+ config_diagnostics: Json;
108
+ config_sources: Json;
109
+ created_at: Timestamp;
110
+ }
111
+ export interface WorkspaceOperationalSettingsTable {
112
+ workspace_id: string;
113
+ timezone: Generated<string>;
114
+ updated_at: Timestamp;
115
+ }
116
+ export interface ReviewerAbsencesTable {
117
+ id: Generated<string>;
118
+ workspace_id: string;
119
+ provider: ProviderKind;
120
+ provider_connection_id: string;
121
+ external_actor_id: string;
122
+ start_at: Timestamp;
123
+ end_at: Timestamp;
124
+ status: Generated<"scheduled" | "cancelled">;
125
+ revision: Generated<number>;
126
+ cancelled_at: NullableTimestamp;
127
+ created_at: Timestamp;
128
+ updated_at: Timestamp;
129
+ }
130
+ export interface ReviewerReplacementsTable {
131
+ id: Generated<string>;
132
+ workspace_id: string;
133
+ provider: ProviderKind;
134
+ provider_connection_id: string;
135
+ absence_id: string;
136
+ absence_revision: number;
137
+ decision_id: string;
138
+ unavailable_actor_id: string;
139
+ replacement_actor_id: NullableString;
140
+ mutation_intent_id: NullableString;
141
+ outcome: ReviewerReplacementOutcome;
142
+ reason: string;
143
+ state: Generated<string>;
144
+ last_error: NullableString;
145
+ started_at: Timestamp;
146
+ completed_at: Timestamp;
147
+ }
148
+ export interface ReviewerMutationIntentsTable {
149
+ id: Generated<string>;
150
+ workspace_id: string;
151
+ provider: ProviderKind;
152
+ provider_connection_id: string;
153
+ absence_id: string;
154
+ absence_revision: number;
155
+ decision_id: string;
156
+ repository_record_id: string;
157
+ repository_id: string;
158
+ change_request_id: string;
159
+ expected_head_revision: string;
160
+ unavailable_actor_id: string;
161
+ replacement_actor_id: string;
162
+ created_at: Timestamp;
163
+ }
164
+ export interface DecisionOutboxTable {
165
+ id: Generated<string>;
166
+ workspace_id: string;
167
+ decision_id: NullableString;
168
+ reviewer_replacement_id: NullableString;
169
+ event_id: string;
170
+ event_type: PlatformEventV1["eventType"];
171
+ schema_version: number;
172
+ payload: Json;
173
+ occurred_at: Timestamp;
174
+ available_at: Timestamp;
175
+ published_at: NullableTimestamp;
176
+ attempt_count: Generated<number>;
177
+ last_error: string | null;
178
+ }
179
+ export interface WorkerHeartbeatTable {
180
+ id: ColumnType<boolean, boolean | undefined, never>;
181
+ worker_id: string;
182
+ heartbeat_at: Timestamp;
183
+ }
184
+ export interface Database {
185
+ workspaces: WorkspacesTable;
186
+ provider_connections: ProviderConnectionsTable;
187
+ provider_connection_revocations: ProviderConnectionRevocationsTable;
188
+ repositories: RepositoriesTable;
189
+ webhook_receipts: WebhookReceiptsTable;
190
+ jobs: JobsTable;
191
+ routing_decisions: RoutingDecisionsTable;
192
+ workspace_operational_settings: WorkspaceOperationalSettingsTable;
193
+ reviewer_absences: ReviewerAbsencesTable;
194
+ reviewer_mutation_intents: ReviewerMutationIntentsTable;
195
+ reviewer_replacements: ReviewerReplacementsTable;
196
+ decision_outbox: DecisionOutboxTable;
197
+ worker_heartbeat: WorkerHeartbeatTable;
198
+ }
199
+ export type JobRow = Selectable<JobsTable>;
200
+ export type NewJobRow = Insertable<JobsTable>;
201
+ export type JobRowUpdate = Updateable<JobsTable>;
202
+ export {};
package/dist/kysely.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare function runMigrations(databaseUrl: string): Promise<void>;
@@ -0,0 +1,45 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import pg from "pg";
5
+ export async function runMigrations(databaseUrl) {
6
+ const pool = new pg.Pool({ connectionString: databaseUrl });
7
+ const client = await pool.connect();
8
+ try {
9
+ await client.query(`
10
+ create table if not exists schema_migrations (
11
+ name text primary key,
12
+ applied_at timestamptz not null default now()
13
+ )
14
+ `);
15
+ const migrationDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../migrations");
16
+ const files = (await fs.readdir(migrationDir)).filter((file) => file.endsWith(".sql")).sort();
17
+ for (const file of files) {
18
+ const existing = await client.query("select name from schema_migrations where name = $1", [file]);
19
+ if (existing.rowCount && existing.rowCount > 0)
20
+ continue;
21
+ const sql = await fs.readFile(path.join(migrationDir, file), "utf8");
22
+ await client.query("begin");
23
+ try {
24
+ await client.query(sql);
25
+ await client.query("insert into schema_migrations (name) values ($1)", [file]);
26
+ await client.query("commit");
27
+ console.log(`applied: ${file}`);
28
+ }
29
+ catch (error) {
30
+ await client.query("rollback");
31
+ throw error;
32
+ }
33
+ }
34
+ }
35
+ finally {
36
+ client.release();
37
+ await pool.end();
38
+ }
39
+ }
40
+ if (import.meta.url === `file://${process.argv[1]}`) {
41
+ const databaseUrl = process.env.DATABASE_URL;
42
+ if (!databaseUrl)
43
+ throw new Error("DATABASE_URL is required");
44
+ await runMigrations(databaseUrl);
45
+ }
@@ -0,0 +1,77 @@
1
+ import { type Kysely } from "kysely";
2
+ import type { ActionStatus, ProviderConnectionId, RepositoryMode, RepositoryRef, RiskTier, RoutingAction, ScoreComponent, WorkspaceId } from "@triagepilot/contracts";
3
+ import type { Database } from "./kysely.js";
4
+ type PolicyCheckState = "not_started" | "in_progress" | "success" | "failure";
5
+ export interface RepositoryOverview {
6
+ id: string;
7
+ owner: string;
8
+ name: string;
9
+ configState: string;
10
+ mode: RepositoryMode;
11
+ }
12
+ export interface DecisionOverview {
13
+ id: string;
14
+ repository: string;
15
+ pullNumber: number | null;
16
+ mode: RepositoryMode;
17
+ action: RoutingAction;
18
+ actionStatus: ActionStatus;
19
+ actionError: string | null;
20
+ policyCheckState: PolicyCheckState;
21
+ riskScore: number;
22
+ riskBreakdown: RiskBreakdown | null;
23
+ requestedReviewerCount: number | null;
24
+ reviewerShortfall: number | null;
25
+ selectedReviewer: string | null;
26
+ selectedReviewers: string[];
27
+ createdAt: string;
28
+ }
29
+ export interface RiskBreakdown {
30
+ classifierVersion: string;
31
+ tier: RiskTier;
32
+ components: ScoreComponent[];
33
+ }
34
+ export interface JobFailureOverview {
35
+ id: string;
36
+ error: string;
37
+ failedAt: string;
38
+ }
39
+ export interface ActionFailureOverview {
40
+ decisionId: string;
41
+ repository: string;
42
+ error: string;
43
+ failedAt: string;
44
+ }
45
+ export interface OperationsOverview {
46
+ organization: string;
47
+ githubApp: {
48
+ appId: string;
49
+ configured: boolean;
50
+ installationId: string | null;
51
+ };
52
+ repositories: RepositoryOverview[];
53
+ decisions: DecisionOverview[];
54
+ failures: {
55
+ jobs: JobFailureOverview[];
56
+ actions: ActionFailureOverview[];
57
+ };
58
+ worker: {
59
+ available: boolean;
60
+ workerId: string | null;
61
+ lastHeartbeatAt: string | null;
62
+ };
63
+ }
64
+ export interface ReadOperationsOverviewInput {
65
+ githubOrganization: string;
66
+ githubAppId: string;
67
+ now: Date;
68
+ heartbeatStaleAfterMs: number;
69
+ }
70
+ export interface RepositoryConfigurationTarget {
71
+ providerConnectionId: ProviderConnectionId;
72
+ externalConnectionId: string;
73
+ repository: RepositoryRef;
74
+ }
75
+ export declare function findRepositoryConfigurationTarget(db: Kysely<Database>, workspaceId: WorkspaceId, repositoryId: string): Promise<RepositoryConfigurationTarget | null>;
76
+ export declare function readOperationsOverview(db: Kysely<Database>, workspaceId: WorkspaceId, input: ReadOperationsOverviewInput): Promise<OperationsOverview>;
77
+ export {};
@@ -0,0 +1,256 @@
1
+ import { sql } from "kysely";
2
+ export async function findRepositoryConfigurationTarget(db, workspaceId, repositoryId) {
3
+ const target = await db
4
+ .selectFrom("repositories")
5
+ .innerJoin("provider_connections", (join) => join
6
+ .onRef("provider_connections.id", "=", "repositories.provider_connection_id")
7
+ .onRef("provider_connections.workspace_id", "=", "repositories.workspace_id"))
8
+ .select([
9
+ "repositories.provider",
10
+ "repositories.external_repository_id",
11
+ "repositories.owner",
12
+ "repositories.name",
13
+ "repositories.provider_connection_id",
14
+ "provider_connections.external_connection_id",
15
+ ])
16
+ .where("repositories.workspace_id", "=", workspaceId)
17
+ .where("repositories.id", "=", repositoryId)
18
+ .where("provider_connections.status", "=", "active")
19
+ .executeTakeFirst();
20
+ if (!target)
21
+ return null;
22
+ return {
23
+ providerConnectionId: target.provider_connection_id,
24
+ externalConnectionId: target.external_connection_id,
25
+ repository: {
26
+ provider: target.provider,
27
+ externalId: target.external_repository_id,
28
+ owner: target.owner,
29
+ name: target.name,
30
+ },
31
+ };
32
+ }
33
+ export async function readOperationsOverview(db, workspaceId, input) {
34
+ const configuredOrganization = sql `lower(provider_connections.workspace_login) = lower(${input.githubOrganization})`;
35
+ const [installation, repositories, decisions, jobFailures, actionFailures, heartbeat] = await Promise.all([
36
+ db
37
+ .selectFrom("provider_connections")
38
+ .select("external_connection_id")
39
+ .where("workspace_id", "=", workspaceId)
40
+ .where("provider", "=", "github")
41
+ .where("status", "=", "active")
42
+ .where(sql `lower(workspace_login) = lower(${input.githubOrganization})`)
43
+ .executeTakeFirst(),
44
+ db
45
+ .selectFrom("repositories")
46
+ .innerJoin("provider_connections", "provider_connections.id", "repositories.provider_connection_id")
47
+ .select([
48
+ "repositories.id",
49
+ "repositories.owner",
50
+ "repositories.name",
51
+ "repositories.config_state",
52
+ "repositories.last_config_mode",
53
+ ])
54
+ .where("repositories.workspace_id", "=", workspaceId)
55
+ .whereRef("provider_connections.workspace_id", "=", "repositories.workspace_id")
56
+ .where("provider_connections.status", "=", "active")
57
+ .where(configuredOrganization)
58
+ .orderBy("repositories.owner", "asc")
59
+ .orderBy("repositories.name", "asc")
60
+ .execute(),
61
+ db
62
+ .selectFrom("routing_decisions")
63
+ .innerJoin("repositories", "repositories.id", "routing_decisions.repository_id")
64
+ .innerJoin("provider_connections", "provider_connections.id", "repositories.provider_connection_id")
65
+ .select([
66
+ "routing_decisions.id",
67
+ "repositories.owner",
68
+ "repositories.name",
69
+ "routing_decisions.mode",
70
+ "routing_decisions.action",
71
+ "routing_decisions.action_status",
72
+ "routing_decisions.action_error",
73
+ "routing_decisions.policy_check_state",
74
+ "routing_decisions.risk_score",
75
+ "routing_decisions.selected_reviewer",
76
+ "routing_decisions.selected_reviewers",
77
+ "routing_decisions.details",
78
+ "routing_decisions.created_at",
79
+ sql `case
80
+ when jsonb_typeof(routing_decisions.details -> 'pullNumber') = 'number'
81
+ and routing_decisions.details ->> 'pullNumber' ~ '^[1-9][0-9]{0,9}$'
82
+ then case
83
+ when (routing_decisions.details ->> 'pullNumber')::numeric <= 2147483647
84
+ then (routing_decisions.details ->> 'pullNumber')::integer
85
+ else null
86
+ end
87
+ else null
88
+ end`.as("pull_number"),
89
+ ])
90
+ .where("routing_decisions.workspace_id", "=", workspaceId)
91
+ .whereRef("repositories.workspace_id", "=", "routing_decisions.workspace_id")
92
+ .whereRef("provider_connections.workspace_id", "=", "repositories.workspace_id")
93
+ .where("provider_connections.status", "=", "active")
94
+ .where(configuredOrganization)
95
+ .orderBy("routing_decisions.created_at", "desc")
96
+ .orderBy("routing_decisions.id", "desc")
97
+ .limit(50)
98
+ .execute(),
99
+ db
100
+ .selectFrom("jobs")
101
+ .select(["id", "last_error", "updated_at"])
102
+ .where("workspace_id", "=", workspaceId)
103
+ .where("status", "=", "failed")
104
+ .orderBy("updated_at", "desc")
105
+ .orderBy("id", "desc")
106
+ .limit(25)
107
+ .execute(),
108
+ db
109
+ .selectFrom("routing_decisions")
110
+ .innerJoin("repositories", "repositories.id", "routing_decisions.repository_id")
111
+ .innerJoin("provider_connections", "provider_connections.id", "repositories.provider_connection_id")
112
+ .select([
113
+ "routing_decisions.id",
114
+ "repositories.owner",
115
+ "repositories.name",
116
+ "routing_decisions.action_error",
117
+ "routing_decisions.action_failed_at",
118
+ ])
119
+ .where("routing_decisions.workspace_id", "=", workspaceId)
120
+ .whereRef("repositories.workspace_id", "=", "routing_decisions.workspace_id")
121
+ .whereRef("provider_connections.workspace_id", "=", "repositories.workspace_id")
122
+ .where("routing_decisions.action_status", "=", "failed")
123
+ .where("routing_decisions.action_failed_at", "is not", null)
124
+ .where("provider_connections.status", "=", "active")
125
+ .where(configuredOrganization)
126
+ .orderBy("routing_decisions.action_failed_at", "desc")
127
+ .orderBy("routing_decisions.id", "desc")
128
+ .limit(25)
129
+ .execute(),
130
+ db
131
+ .selectFrom("worker_heartbeat")
132
+ .select(["worker_id", "heartbeat_at"])
133
+ .executeTakeFirst(),
134
+ ]);
135
+ return {
136
+ organization: input.githubOrganization,
137
+ githubApp: {
138
+ appId: input.githubAppId,
139
+ configured: input.githubAppId.length > 0,
140
+ installationId: installation?.external_connection_id ?? null,
141
+ },
142
+ repositories: repositories.map((repository) => ({
143
+ id: repository.id,
144
+ owner: repository.owner,
145
+ name: repository.name,
146
+ configState: repository.config_state,
147
+ mode: repository.last_config_mode,
148
+ })),
149
+ decisions: decisions.map((decision) => {
150
+ const selectedReviewers = readSelectedReviewers(decision.selected_reviewers, decision.selected_reviewer);
151
+ const reviewerQuota = readReviewerQuota(decision.details, selectedReviewers);
152
+ return {
153
+ id: decision.id,
154
+ repository: `${decision.owner}/${decision.name}`,
155
+ pullNumber: decision.pull_number,
156
+ mode: decision.mode,
157
+ action: decision.action,
158
+ actionStatus: decision.action_status,
159
+ actionError: decision.action_error,
160
+ policyCheckState: normalizePolicyCheckState(decision.policy_check_state),
161
+ riskScore: decision.risk_score,
162
+ riskBreakdown: readRiskBreakdown(decision.details),
163
+ ...reviewerQuota,
164
+ selectedReviewer: decision.selected_reviewer,
165
+ selectedReviewers,
166
+ createdAt: decision.created_at.toISOString(),
167
+ };
168
+ }),
169
+ failures: {
170
+ jobs: jobFailures.map((failure) => ({
171
+ id: failure.id,
172
+ error: failure.last_error ?? "Unknown job failure",
173
+ failedAt: failure.updated_at.toISOString(),
174
+ })),
175
+ actions: actionFailures.map((failure) => ({
176
+ decisionId: failure.id,
177
+ repository: `${failure.owner}/${failure.name}`,
178
+ error: failure.action_error ?? "Unknown action failure",
179
+ failedAt: failure.action_failed_at.toISOString(),
180
+ })),
181
+ },
182
+ worker: {
183
+ available: heartbeat !== undefined &&
184
+ input.now.getTime() - heartbeat.heartbeat_at.getTime() <= input.heartbeatStaleAfterMs,
185
+ workerId: heartbeat?.worker_id ?? null,
186
+ lastHeartbeatAt: heartbeat?.heartbeat_at.toISOString() ?? null,
187
+ },
188
+ };
189
+ }
190
+ function normalizePolicyCheckState(value) {
191
+ switch (value) {
192
+ case "in_progress":
193
+ case "success":
194
+ case "failure":
195
+ return value;
196
+ default:
197
+ return "not_started";
198
+ }
199
+ }
200
+ function readRiskBreakdown(details) {
201
+ const risk = readRecord(readRecord(details)?.risk);
202
+ if (!risk || !isRiskTier(risk.tier) || typeof risk.classifierVersion !== "string")
203
+ return null;
204
+ if (!Array.isArray(risk.components) || !risk.components.every(isScoreComponent))
205
+ return null;
206
+ return {
207
+ classifierVersion: risk.classifierVersion,
208
+ tier: risk.tier,
209
+ components: risk.components,
210
+ };
211
+ }
212
+ function readRecord(value) {
213
+ return value !== null && typeof value === "object" && !Array.isArray(value)
214
+ ? value
215
+ : null;
216
+ }
217
+ function isRiskTier(value) {
218
+ return value === "low" || value === "medium" || value === "high";
219
+ }
220
+ function isScoreComponent(value) {
221
+ const component = readRecord(value);
222
+ return (component !== null &&
223
+ typeof component.reason === "string" &&
224
+ typeof component.detail === "string" &&
225
+ typeof component.score === "number" &&
226
+ Number.isFinite(component.score));
227
+ }
228
+ function readSelectedReviewers(value, legacyReviewer) {
229
+ if (Array.isArray(value)) {
230
+ return value.filter((reviewer) => typeof reviewer === "string").slice(0, 2);
231
+ }
232
+ return legacyReviewer ? [legacyReviewer] : [];
233
+ }
234
+ function readReviewerQuota(details, selectedReviewers) {
235
+ const routing = readRecord(readRecord(details)?.routing);
236
+ if (routing === null) {
237
+ return { requestedReviewerCount: null, reviewerShortfall: null };
238
+ }
239
+ if (!isReviewerCount(routing.requestedReviewerCount)) {
240
+ return { requestedReviewerCount: null, reviewerShortfall: null };
241
+ }
242
+ const requestedReviewerCount = routing.requestedReviewerCount;
243
+ const fallbackShortfall = Math.max(0, requestedReviewerCount - selectedReviewers.length);
244
+ return {
245
+ requestedReviewerCount,
246
+ reviewerShortfall: typeof routing.reviewerShortfall === "number" &&
247
+ Number.isInteger(routing.reviewerShortfall) &&
248
+ routing.reviewerShortfall >= 0 &&
249
+ routing.reviewerShortfall <= requestedReviewerCount
250
+ ? routing.reviewerShortfall
251
+ : fallbackShortfall,
252
+ };
253
+ }
254
+ function isReviewerCount(value) {
255
+ return value === 0 || value === 1 || value === 2;
256
+ }
@@ -0,0 +1,61 @@
1
+ import type { Kysely, Transaction } from "kysely";
2
+ import type { PlatformEventSink, PlatformEventV1, WorkspaceId } from "@triagepilot/contracts";
3
+ import type { Database } from "./kysely.js";
4
+ type DatabaseExecutor = Kysely<Database> | Transaction<Database>;
5
+ export interface PlatformOutboxRecord {
6
+ id: string;
7
+ workspaceId: WorkspaceId;
8
+ decisionId: string | null;
9
+ reviewerReplacementId: string | null;
10
+ eventId: string;
11
+ eventType: PlatformEventV1["eventType"];
12
+ schemaVersion: number;
13
+ payload: PlatformEventV1;
14
+ occurredAt: Date;
15
+ availableAt: Date;
16
+ publishedAt: Date | null;
17
+ attemptCount: number;
18
+ lastError: string | null;
19
+ }
20
+ export interface PlatformOutboxRepository {
21
+ claim(input: {
22
+ limit: number;
23
+ now: Date;
24
+ }): Promise<PlatformOutboxRecord[]>;
25
+ markPublished(input: {
26
+ id: string;
27
+ attemptCount: number;
28
+ now: Date;
29
+ }): Promise<boolean>;
30
+ markFailed(input: {
31
+ id: string;
32
+ attemptCount: number;
33
+ error: unknown;
34
+ now: Date;
35
+ }): Promise<boolean>;
36
+ listUnpublished(): Promise<PlatformOutboxRecord[]>;
37
+ }
38
+ export declare function stagePlatformEvent(db: DatabaseExecutor, workspaceId: WorkspaceId, sourceId: string, event: PlatformEventV1): Promise<void>;
39
+ export declare function claimPlatformEvents(input: {
40
+ db: Kysely<Database>;
41
+ workspaceId: WorkspaceId;
42
+ limit: number;
43
+ now?: Date;
44
+ }): Promise<PlatformOutboxRecord[]>;
45
+ export declare function markPlatformEventPublished(input: {
46
+ db: Kysely<Database>;
47
+ workspaceId: WorkspaceId;
48
+ id: string;
49
+ attemptCount: number;
50
+ now?: Date;
51
+ }): Promise<boolean>;
52
+ export declare function createPlatformOutboxRepository(db: Kysely<Database>, workspaceId: WorkspaceId): PlatformOutboxRepository;
53
+ export declare function publishPlatformOutbox(input: {
54
+ repository: PlatformOutboxRepository;
55
+ sink: PlatformEventSink;
56
+ limit: number;
57
+ now?: Date;
58
+ }): Promise<{
59
+ published: number;
60
+ }>;
61
+ export {};