@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.
- package/LICENSE +105 -0
- package/dist/availability.d.ts +164 -0
- package/dist/availability.js +974 -0
- package/dist/database.d.ts +3 -0
- package/dist/database.js +9 -0
- package/dist/decisions.d.ts +107 -0
- package/dist/decisions.js +435 -0
- package/dist/deliveries.d.ts +30 -0
- package/dist/deliveries.js +82 -0
- package/dist/heartbeat.d.ts +11 -0
- package/dist/heartbeat.js +11 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -0
- package/dist/jobs.d.ts +86 -0
- package/dist/jobs.js +641 -0
- package/dist/kysely.d.ts +202 -0
- package/dist/kysely.js +1 -0
- package/dist/migrate.d.ts +1 -0
- package/dist/migrate.js +45 -0
- package/dist/operations.d.ts +77 -0
- package/dist/operations.js +256 -0
- package/dist/outbox.d.ts +61 -0
- package/dist/outbox.js +230 -0
- package/dist/provider-connections.d.ts +34 -0
- package/dist/provider-connections.js +302 -0
- package/dist/retention.d.ts +6 -0
- package/dist/retention.js +44 -0
- package/dist/routing-recovery.d.ts +55 -0
- package/dist/routing-recovery.js +175 -0
- package/dist/workspaces.d.ts +50 -0
- package/dist/workspaces.js +43 -0
- package/migrations/0001_initial.sql +79 -0
- package/migrations/0002_selected_reviewers.sql +10 -0
- package/migrations/0003_human_review_policy.sql +10 -0
- package/migrations/0004_semantic_routing_deduplication.sql +15 -0
- package/migrations/0005_reviewer_availability.sql +58 -0
- package/migrations/0005_workspace_scope.sql +198 -0
- package/migrations/0006_decision_outbox.sql +24 -0
- package/migrations/0007_workspace_reviewer_availability.sql +192 -0
- package/migrations/0008_reviewer_mutation_intents.sql +160 -0
- package/migrations/0009_provider_connection_revocations.sql +18 -0
- package/migrations/0010_provider_connection_preemptive_revocations.sql +12 -0
- package/package.json +42 -0
package/dist/outbox.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { buildNextRunAt } from "./jobs.js";
|
|
3
|
+
const PLATFORM_OUTBOX_LEASE_MS = 15 * 60 * 1000;
|
|
4
|
+
export async function stagePlatformEvent(db, workspaceId, sourceId, event) {
|
|
5
|
+
if (db.isTransaction) {
|
|
6
|
+
await stagePlatformEventInTransaction(db, workspaceId, sourceId, event);
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
await db.transaction().execute(async (trx) => {
|
|
10
|
+
await stagePlatformEventInTransaction(trx, workspaceId, sourceId, event);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
async function stagePlatformEventInTransaction(db, workspaceId, sourceId, event) {
|
|
14
|
+
if (event.workspaceId !== workspaceId)
|
|
15
|
+
throw new Error("platform event workspace does not match persistence scope");
|
|
16
|
+
if (event.eventType === "routing_decision" && event.decisionId !== sourceId) {
|
|
17
|
+
throw new Error("platform event decision id does not match persisted decision");
|
|
18
|
+
}
|
|
19
|
+
if (event.eventType === "reviewer_replacement") {
|
|
20
|
+
const replacement = await db
|
|
21
|
+
.selectFrom("reviewer_replacements")
|
|
22
|
+
.select([
|
|
23
|
+
"provider",
|
|
24
|
+
"provider_connection_id",
|
|
25
|
+
"absence_id",
|
|
26
|
+
"absence_revision",
|
|
27
|
+
"decision_id",
|
|
28
|
+
"unavailable_actor_id",
|
|
29
|
+
"replacement_actor_id",
|
|
30
|
+
"outcome",
|
|
31
|
+
])
|
|
32
|
+
.where("workspace_id", "=", workspaceId)
|
|
33
|
+
.where("id", "=", sourceId)
|
|
34
|
+
.forUpdate()
|
|
35
|
+
.executeTakeFirst();
|
|
36
|
+
if (replacement === undefined
|
|
37
|
+
|| replacement.provider !== event.provider
|
|
38
|
+
|| replacement.provider_connection_id !== event.providerConnectionId
|
|
39
|
+
|| replacement.absence_id !== event.absenceId
|
|
40
|
+
|| replacement.absence_revision !== event.absenceRevision
|
|
41
|
+
|| replacement.decision_id !== event.decisionId
|
|
42
|
+
|| replacement.unavailable_actor_id !== event.unavailableActor
|
|
43
|
+
|| replacement.replacement_actor_id !== event.replacementActor
|
|
44
|
+
|| replacement.outcome !== event.outcome) {
|
|
45
|
+
throw new Error("reviewer replacement event does not match persisted source");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const source = event.eventType === "routing_decision"
|
|
49
|
+
? { decision_id: sourceId, reviewer_replacement_id: null }
|
|
50
|
+
: { decision_id: null, reviewer_replacement_id: sourceId };
|
|
51
|
+
const inserted = await db
|
|
52
|
+
.insertInto("decision_outbox")
|
|
53
|
+
.values({
|
|
54
|
+
workspace_id: workspaceId,
|
|
55
|
+
...source,
|
|
56
|
+
event_id: event.eventId,
|
|
57
|
+
event_type: event.eventType,
|
|
58
|
+
schema_version: event.schemaVersion,
|
|
59
|
+
payload: event,
|
|
60
|
+
occurred_at: event.occurredAt,
|
|
61
|
+
available_at: event.occurredAt,
|
|
62
|
+
})
|
|
63
|
+
.onConflict((conflict) => conflict.columns(["workspace_id", "event_id"]).doNothing())
|
|
64
|
+
.returning("id")
|
|
65
|
+
.executeTakeFirst();
|
|
66
|
+
if (inserted !== undefined)
|
|
67
|
+
return;
|
|
68
|
+
const existing = await db
|
|
69
|
+
.selectFrom("decision_outbox")
|
|
70
|
+
.select([
|
|
71
|
+
"decision_id",
|
|
72
|
+
"reviewer_replacement_id",
|
|
73
|
+
"event_type",
|
|
74
|
+
"schema_version",
|
|
75
|
+
"payload",
|
|
76
|
+
"occurred_at",
|
|
77
|
+
])
|
|
78
|
+
.where("workspace_id", "=", workspaceId)
|
|
79
|
+
.where("event_id", "=", event.eventId)
|
|
80
|
+
.forUpdate()
|
|
81
|
+
.executeTakeFirst();
|
|
82
|
+
if (existing === undefined
|
|
83
|
+
|| existing.decision_id !== source.decision_id
|
|
84
|
+
|| existing.reviewer_replacement_id !== source.reviewer_replacement_id
|
|
85
|
+
|| existing.event_type !== event.eventType
|
|
86
|
+
|| existing.schema_version !== event.schemaVersion
|
|
87
|
+
|| existing.occurred_at.getTime() !== new Date(event.occurredAt).getTime()
|
|
88
|
+
|| !isDeepStrictEqual(existing.payload, event)) {
|
|
89
|
+
throw new Error("platform event id conflicts with a different persisted event");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export async function claimPlatformEvents(input) {
|
|
93
|
+
return await createPlatformOutboxRepository(input.db, input.workspaceId).claim({
|
|
94
|
+
limit: input.limit,
|
|
95
|
+
now: input.now ?? new Date(),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
export async function markPlatformEventPublished(input) {
|
|
99
|
+
return await createPlatformOutboxRepository(input.db, input.workspaceId).markPublished({
|
|
100
|
+
id: input.id,
|
|
101
|
+
attemptCount: input.attemptCount,
|
|
102
|
+
now: input.now ?? new Date(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export function createPlatformOutboxRepository(db, workspaceId) {
|
|
106
|
+
return {
|
|
107
|
+
async claim(input) {
|
|
108
|
+
if (input.limit <= 0)
|
|
109
|
+
return [];
|
|
110
|
+
return await db.transaction().execute(async (trx) => {
|
|
111
|
+
const candidates = await trx
|
|
112
|
+
.selectFrom("decision_outbox")
|
|
113
|
+
.selectAll()
|
|
114
|
+
.where("workspace_id", "=", workspaceId)
|
|
115
|
+
.where("published_at", "is", null)
|
|
116
|
+
.where("available_at", "<=", input.now)
|
|
117
|
+
.orderBy("available_at", "asc")
|
|
118
|
+
.orderBy("occurred_at", "asc")
|
|
119
|
+
.orderBy("id", "asc")
|
|
120
|
+
.limit(input.limit)
|
|
121
|
+
.forUpdate()
|
|
122
|
+
.skipLocked()
|
|
123
|
+
.execute();
|
|
124
|
+
const claimed = [];
|
|
125
|
+
for (const candidate of candidates) {
|
|
126
|
+
const nextAttemptCount = candidate.attempt_count + 1;
|
|
127
|
+
const updated = await trx
|
|
128
|
+
.updateTable("decision_outbox")
|
|
129
|
+
.set({
|
|
130
|
+
attempt_count: nextAttemptCount,
|
|
131
|
+
available_at: buildLeaseExpiresAt(input.now),
|
|
132
|
+
})
|
|
133
|
+
.where("workspace_id", "=", workspaceId)
|
|
134
|
+
.where("id", "=", candidate.id)
|
|
135
|
+
.where("published_at", "is", null)
|
|
136
|
+
.returningAll()
|
|
137
|
+
.executeTakeFirst();
|
|
138
|
+
if (updated)
|
|
139
|
+
claimed.push(toPlatformOutboxRecord(updated));
|
|
140
|
+
}
|
|
141
|
+
return claimed;
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
async markPublished(input) {
|
|
145
|
+
const updated = await db
|
|
146
|
+
.updateTable("decision_outbox")
|
|
147
|
+
.set({ published_at: input.now, last_error: null })
|
|
148
|
+
.where("workspace_id", "=", workspaceId)
|
|
149
|
+
.where("id", "=", input.id)
|
|
150
|
+
.where("attempt_count", "=", input.attemptCount)
|
|
151
|
+
.where("published_at", "is", null)
|
|
152
|
+
.returning("id")
|
|
153
|
+
.execute();
|
|
154
|
+
return updated.length > 0;
|
|
155
|
+
},
|
|
156
|
+
async markFailed(input) {
|
|
157
|
+
const updated = await db
|
|
158
|
+
.updateTable("decision_outbox")
|
|
159
|
+
.set({
|
|
160
|
+
last_error: sanitizeError(input.error),
|
|
161
|
+
available_at: buildNextRunAt(input.now, input.attemptCount),
|
|
162
|
+
})
|
|
163
|
+
.where("workspace_id", "=", workspaceId)
|
|
164
|
+
.where("id", "=", input.id)
|
|
165
|
+
.where("attempt_count", "=", input.attemptCount)
|
|
166
|
+
.where("published_at", "is", null)
|
|
167
|
+
.returning("id")
|
|
168
|
+
.execute();
|
|
169
|
+
return updated.length > 0;
|
|
170
|
+
},
|
|
171
|
+
async listUnpublished() {
|
|
172
|
+
const rows = await db
|
|
173
|
+
.selectFrom("decision_outbox")
|
|
174
|
+
.selectAll()
|
|
175
|
+
.where("workspace_id", "=", workspaceId)
|
|
176
|
+
.where("published_at", "is", null)
|
|
177
|
+
.orderBy("occurred_at", "asc")
|
|
178
|
+
.orderBy("id", "asc")
|
|
179
|
+
.execute();
|
|
180
|
+
return rows.map(toPlatformOutboxRecord);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export async function publishPlatformOutbox(input) {
|
|
185
|
+
const now = input.now ?? new Date();
|
|
186
|
+
const events = await input.repository.claim({ limit: input.limit, now });
|
|
187
|
+
let published = 0;
|
|
188
|
+
for (const event of events) {
|
|
189
|
+
try {
|
|
190
|
+
await input.sink.emit(event.payload);
|
|
191
|
+
if (await input.repository.markPublished({ id: event.id, attemptCount: event.attemptCount, now })) {
|
|
192
|
+
published += 1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
await input.repository.markFailed({
|
|
197
|
+
id: event.id,
|
|
198
|
+
attemptCount: event.attemptCount,
|
|
199
|
+
error,
|
|
200
|
+
now,
|
|
201
|
+
});
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { published };
|
|
206
|
+
}
|
|
207
|
+
function toPlatformOutboxRecord(row) {
|
|
208
|
+
return {
|
|
209
|
+
id: row.id,
|
|
210
|
+
workspaceId: row.workspace_id,
|
|
211
|
+
decisionId: row.decision_id,
|
|
212
|
+
reviewerReplacementId: row.reviewer_replacement_id,
|
|
213
|
+
eventId: row.event_id,
|
|
214
|
+
eventType: row.event_type,
|
|
215
|
+
schemaVersion: row.schema_version,
|
|
216
|
+
payload: row.payload,
|
|
217
|
+
occurredAt: row.occurred_at,
|
|
218
|
+
availableAt: row.available_at,
|
|
219
|
+
publishedAt: row.published_at,
|
|
220
|
+
attemptCount: row.attempt_count,
|
|
221
|
+
lastError: row.last_error,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function sanitizeError(error) {
|
|
225
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
226
|
+
return message.replaceAll(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, 1_000) || "platform event sink failed";
|
|
227
|
+
}
|
|
228
|
+
function buildLeaseExpiresAt(now) {
|
|
229
|
+
return new Date(now.getTime() + PLATFORM_OUTBOX_LEASE_MS);
|
|
230
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Kysely, type Transaction } from "kysely";
|
|
2
|
+
import type { ProviderConnectionId, ProviderKind, RepositoryId, WorkspaceId } from "@triagepilot/contracts";
|
|
3
|
+
import type { Database } from "./kysely.js";
|
|
4
|
+
export interface ProviderConnectionMetadata {
|
|
5
|
+
provider: ProviderKind;
|
|
6
|
+
externalConnectionId: string;
|
|
7
|
+
workspaceLogin: string;
|
|
8
|
+
accountType: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ProviderRepositoryMetadata {
|
|
11
|
+
provider: ProviderKind;
|
|
12
|
+
externalRepositoryId: RepositoryId;
|
|
13
|
+
owner: string;
|
|
14
|
+
name: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ConfiguredProviderConnectionInput extends ProviderConnectionMetadata {
|
|
17
|
+
repositories: ProviderRepositoryMetadata[];
|
|
18
|
+
}
|
|
19
|
+
export interface ProviderConnectionRepositoryUpdateInput extends ProviderConnectionMetadata {
|
|
20
|
+
repositoriesAdded: ProviderRepositoryMetadata[];
|
|
21
|
+
repositoryIdsRemoved: RepositoryId[];
|
|
22
|
+
}
|
|
23
|
+
export declare function upsertConfiguredProviderConnection(db: Kysely<Database>, workspaceId: WorkspaceId, input: ConfiguredProviderConnectionInput): Promise<void>;
|
|
24
|
+
export declare function activateConfiguredProviderConnection(db: Kysely<Database>, workspaceId: WorkspaceId, input: ProviderConnectionMetadata): Promise<void>;
|
|
25
|
+
export declare function replaceProviderConnectionRepositories(db: Kysely<Database>, workspaceId: WorkspaceId, input: ConfiguredProviderConnectionInput): Promise<void>;
|
|
26
|
+
export declare function updateProviderConnectionRepositories(db: Kysely<Database>, workspaceId: WorkspaceId, input: ProviderConnectionRepositoryUpdateInput): Promise<void>;
|
|
27
|
+
export declare function suspendConfiguredProviderConnection(db: Kysely<Database>, workspaceId: WorkspaceId, input: ProviderConnectionMetadata): Promise<void>;
|
|
28
|
+
export declare function revokeConfiguredProviderConnection(db: Kysely<Database>, workspaceId: WorkspaceId, input: Pick<ProviderConnectionMetadata, "provider" | "externalConnectionId">): Promise<void>;
|
|
29
|
+
export declare function cleanupRevokedProviderConnections(db: Kysely<Database>, workspaceId: WorkspaceId, now: Date, limit?: number): Promise<number>;
|
|
30
|
+
export declare function upsertDeliveryRepository(trx: Transaction<Database>, workspaceId: WorkspaceId, connection: ProviderConnectionMetadata, repository: ProviderRepositoryMetadata): Promise<{
|
|
31
|
+
providerConnectionId: ProviderConnectionId;
|
|
32
|
+
repositoryId: string;
|
|
33
|
+
} | null>;
|
|
34
|
+
export declare function lockProviderConnectionProjection(trx: Transaction<Database>, workspaceId: WorkspaceId): Promise<void>;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
export async function upsertConfiguredProviderConnection(db, workspaceId, input) {
|
|
3
|
+
await replaceProviderConnectionRepositories(db, workspaceId, input);
|
|
4
|
+
}
|
|
5
|
+
export async function activateConfiguredProviderConnection(db, workspaceId, input) {
|
|
6
|
+
await db.transaction().execute(async (trx) => {
|
|
7
|
+
await upsertActiveProviderConnection(trx, workspaceId, input);
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export async function replaceProviderConnectionRepositories(db, workspaceId, input) {
|
|
11
|
+
assertRepositoryProviders(input.provider, input.repositories);
|
|
12
|
+
await db.transaction().execute(async (trx) => {
|
|
13
|
+
const providerConnectionId = await upsertActiveProviderConnection(trx, workspaceId, input);
|
|
14
|
+
if (providerConnectionId === null)
|
|
15
|
+
return;
|
|
16
|
+
for (const repository of input.repositories) {
|
|
17
|
+
await upsertRepository(trx, workspaceId, providerConnectionId, repository);
|
|
18
|
+
}
|
|
19
|
+
let deletion = trx
|
|
20
|
+
.deleteFrom("repositories")
|
|
21
|
+
.where("workspace_id", "=", workspaceId)
|
|
22
|
+
.where("provider_connection_id", "=", providerConnectionId);
|
|
23
|
+
if (input.repositories.length > 0) {
|
|
24
|
+
deletion = deletion.where("external_repository_id", "not in", input.repositories.map((repository) => repository.externalRepositoryId));
|
|
25
|
+
}
|
|
26
|
+
await deletion.execute();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export async function updateProviderConnectionRepositories(db, workspaceId, input) {
|
|
30
|
+
assertRepositoryProviders(input.provider, input.repositoriesAdded);
|
|
31
|
+
await db.transaction().execute(async (trx) => {
|
|
32
|
+
await lockProviderConnectionProjection(trx, workspaceId);
|
|
33
|
+
const connection = await trx
|
|
34
|
+
.selectFrom("provider_connections")
|
|
35
|
+
.select("id")
|
|
36
|
+
.where("workspace_id", "=", workspaceId)
|
|
37
|
+
.where("provider", "=", input.provider)
|
|
38
|
+
.where("status", "=", "active")
|
|
39
|
+
.where("external_connection_id", "=", input.externalConnectionId)
|
|
40
|
+
.forUpdate()
|
|
41
|
+
.executeTakeFirst();
|
|
42
|
+
if (!connection)
|
|
43
|
+
return;
|
|
44
|
+
await trx
|
|
45
|
+
.updateTable("provider_connections")
|
|
46
|
+
.set({
|
|
47
|
+
workspace_login: input.workspaceLogin,
|
|
48
|
+
account_type: input.accountType,
|
|
49
|
+
updated_at: new Date(),
|
|
50
|
+
})
|
|
51
|
+
.where("workspace_id", "=", workspaceId)
|
|
52
|
+
.where("id", "=", connection.id)
|
|
53
|
+
.execute();
|
|
54
|
+
for (const repository of input.repositoriesAdded) {
|
|
55
|
+
await upsertRepository(trx, workspaceId, connection.id, repository);
|
|
56
|
+
}
|
|
57
|
+
if (input.repositoryIdsRemoved.length > 0) {
|
|
58
|
+
await trx
|
|
59
|
+
.deleteFrom("repositories")
|
|
60
|
+
.where("workspace_id", "=", workspaceId)
|
|
61
|
+
.where("provider_connection_id", "=", connection.id)
|
|
62
|
+
.where("provider", "=", input.provider)
|
|
63
|
+
.where("external_repository_id", "in", input.repositoryIdsRemoved)
|
|
64
|
+
.execute();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export async function suspendConfiguredProviderConnection(db, workspaceId, input) {
|
|
69
|
+
await db.transaction().execute(async (trx) => {
|
|
70
|
+
await lockProviderConnectionProjection(trx, workspaceId);
|
|
71
|
+
await trx
|
|
72
|
+
.updateTable("provider_connections")
|
|
73
|
+
.set({
|
|
74
|
+
workspace_login: input.workspaceLogin,
|
|
75
|
+
account_type: input.accountType,
|
|
76
|
+
status: "suspended",
|
|
77
|
+
updated_at: new Date(),
|
|
78
|
+
})
|
|
79
|
+
.where("workspace_id", "=", workspaceId)
|
|
80
|
+
.where("provider", "=", input.provider)
|
|
81
|
+
.where("external_connection_id", "=", input.externalConnectionId)
|
|
82
|
+
.where("status", "=", "active")
|
|
83
|
+
.execute();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
export async function revokeConfiguredProviderConnection(db, workspaceId, input) {
|
|
87
|
+
await db.transaction().execute(async (trx) => {
|
|
88
|
+
await lockProviderConnectionProjection(trx, workspaceId);
|
|
89
|
+
const connection = await trx
|
|
90
|
+
.selectFrom("provider_connections")
|
|
91
|
+
.select("id")
|
|
92
|
+
.where("workspace_id", "=", workspaceId)
|
|
93
|
+
.where("provider", "=", input.provider)
|
|
94
|
+
.where("external_connection_id", "=", input.externalConnectionId)
|
|
95
|
+
.where("status", "!=", "revoked")
|
|
96
|
+
.forUpdate()
|
|
97
|
+
.executeTakeFirst();
|
|
98
|
+
const revokedAt = new Date();
|
|
99
|
+
await trx.insertInto("provider_connection_revocations").values({
|
|
100
|
+
workspace_id: workspaceId,
|
|
101
|
+
provider: input.provider,
|
|
102
|
+
external_connection_id: input.externalConnectionId,
|
|
103
|
+
...(connection === undefined ? {} : { revoked_connection_id: connection.id }),
|
|
104
|
+
physical_connection_id: connection?.id ?? null,
|
|
105
|
+
revoked_at: revokedAt,
|
|
106
|
+
cleanup_completed_at: null,
|
|
107
|
+
}).onConflict((conflict) => conflict
|
|
108
|
+
.columns(["workspace_id", "provider", "external_connection_id"])
|
|
109
|
+
.doNothing()).execute();
|
|
110
|
+
if (connection === undefined)
|
|
111
|
+
return;
|
|
112
|
+
await trx.updateTable("provider_connections").set({
|
|
113
|
+
status: "revoked",
|
|
114
|
+
updated_at: revokedAt,
|
|
115
|
+
}).where("workspace_id", "=", workspaceId)
|
|
116
|
+
.where("provider", "=", input.provider)
|
|
117
|
+
.where("id", "=", connection.id)
|
|
118
|
+
.execute();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
export async function cleanupRevokedProviderConnections(db, workspaceId, now, limit = 25) {
|
|
122
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
123
|
+
throw new Error("provider connection cleanup limit must be a positive integer");
|
|
124
|
+
}
|
|
125
|
+
const pending = await db.selectFrom("provider_connection_revocations")
|
|
126
|
+
.select(["provider", "revoked_connection_id", "physical_connection_id"])
|
|
127
|
+
.where("workspace_id", "=", workspaceId)
|
|
128
|
+
.where("cleanup_completed_at", "is", null)
|
|
129
|
+
.where("physical_connection_id", "is not", null)
|
|
130
|
+
.orderBy("revoked_at")
|
|
131
|
+
.orderBy("revoked_connection_id")
|
|
132
|
+
.limit(limit)
|
|
133
|
+
.execute();
|
|
134
|
+
let cleaned = 0;
|
|
135
|
+
for (const candidate of pending) {
|
|
136
|
+
const physicalConnectionId = candidate.physical_connection_id;
|
|
137
|
+
if (physicalConnectionId === null)
|
|
138
|
+
continue;
|
|
139
|
+
const completed = await cleanupRevokedProviderConnection(db, workspaceId, { ...candidate, physical_connection_id: physicalConnectionId }, now);
|
|
140
|
+
if (completed)
|
|
141
|
+
cleaned += 1;
|
|
142
|
+
}
|
|
143
|
+
return cleaned;
|
|
144
|
+
}
|
|
145
|
+
async function cleanupRevokedProviderConnection(db, workspaceId, candidate, now) {
|
|
146
|
+
return await db.transaction().execute(async (trx) => {
|
|
147
|
+
const revocation = await trx.selectFrom("provider_connection_revocations")
|
|
148
|
+
.select("revoked_connection_id")
|
|
149
|
+
.where("workspace_id", "=", workspaceId)
|
|
150
|
+
.where("provider", "=", candidate.provider)
|
|
151
|
+
.where("revoked_connection_id", "=", candidate.revoked_connection_id)
|
|
152
|
+
.where("cleanup_completed_at", "is", null)
|
|
153
|
+
.forUpdate()
|
|
154
|
+
.executeTakeFirst();
|
|
155
|
+
if (revocation === undefined)
|
|
156
|
+
return false;
|
|
157
|
+
const deleted = await trx.deleteFrom("provider_connections")
|
|
158
|
+
.where("workspace_id", "=", workspaceId)
|
|
159
|
+
.where("provider", "=", candidate.provider)
|
|
160
|
+
.where("id", "=", candidate.physical_connection_id)
|
|
161
|
+
.where("status", "=", "revoked")
|
|
162
|
+
.where(({ not, exists, selectFrom }) => not(exists(selectFrom("jobs").select("id")
|
|
163
|
+
.where("workspace_id", "=", workspaceId)
|
|
164
|
+
.where("provider", "=", candidate.provider)
|
|
165
|
+
.where("provider_connection_id", "=", candidate.physical_connection_id)
|
|
166
|
+
.where("status", "in", ["queued", "running"]))))
|
|
167
|
+
.returning("id")
|
|
168
|
+
.executeTakeFirst();
|
|
169
|
+
if (deleted === undefined) {
|
|
170
|
+
const connection = await trx.selectFrom("provider_connections").select("status")
|
|
171
|
+
.where("workspace_id", "=", workspaceId)
|
|
172
|
+
.where("provider", "=", candidate.provider)
|
|
173
|
+
.where("id", "=", candidate.physical_connection_id)
|
|
174
|
+
.executeTakeFirst();
|
|
175
|
+
if (connection !== undefined)
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
await trx.updateTable("provider_connection_revocations")
|
|
179
|
+
.set({ cleanup_completed_at: now })
|
|
180
|
+
.where("workspace_id", "=", workspaceId)
|
|
181
|
+
.where("provider", "=", candidate.provider)
|
|
182
|
+
.where("revoked_connection_id", "=", candidate.revoked_connection_id)
|
|
183
|
+
.where("cleanup_completed_at", "is", null)
|
|
184
|
+
.execute();
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
export async function upsertDeliveryRepository(trx, workspaceId, connection, repository) {
|
|
189
|
+
assertRepositoryProviders(connection.provider, [repository]);
|
|
190
|
+
const providerConnectionId = await upsertActiveProviderConnection(trx, workspaceId, connection);
|
|
191
|
+
if (providerConnectionId === null)
|
|
192
|
+
return null;
|
|
193
|
+
const repositoryId = await upsertRepository(trx, workspaceId, providerConnectionId, repository);
|
|
194
|
+
return { providerConnectionId, repositoryId };
|
|
195
|
+
}
|
|
196
|
+
async function upsertActiveProviderConnection(trx, workspaceId, input) {
|
|
197
|
+
await lockProviderConnectionProjection(trx, workspaceId);
|
|
198
|
+
const now = new Date();
|
|
199
|
+
const revocation = await trx.selectFrom("provider_connection_revocations")
|
|
200
|
+
.select("revoked_connection_id")
|
|
201
|
+
.where("workspace_id", "=", workspaceId)
|
|
202
|
+
.where("provider", "=", input.provider)
|
|
203
|
+
.where("external_connection_id", "=", input.externalConnectionId)
|
|
204
|
+
.forUpdate()
|
|
205
|
+
.executeTakeFirst();
|
|
206
|
+
if (revocation !== undefined)
|
|
207
|
+
return null;
|
|
208
|
+
const exact = await trx.selectFrom("provider_connections")
|
|
209
|
+
.select(["id", "status"])
|
|
210
|
+
.where("workspace_id", "=", workspaceId)
|
|
211
|
+
.where("provider", "=", input.provider)
|
|
212
|
+
.where("external_connection_id", "=", input.externalConnectionId)
|
|
213
|
+
.where("status", "!=", "revoked")
|
|
214
|
+
.forUpdate()
|
|
215
|
+
.executeTakeFirst();
|
|
216
|
+
if (exact !== undefined) {
|
|
217
|
+
const updated = await trx.updateTable("provider_connections").set({
|
|
218
|
+
workspace_login: input.workspaceLogin,
|
|
219
|
+
account_type: input.accountType,
|
|
220
|
+
status: "active",
|
|
221
|
+
updated_at: now,
|
|
222
|
+
}).where("workspace_id", "=", workspaceId)
|
|
223
|
+
.where("provider", "=", input.provider)
|
|
224
|
+
.where("id", "=", exact.id)
|
|
225
|
+
.returning("id")
|
|
226
|
+
.executeTakeFirstOrThrow();
|
|
227
|
+
return updated.id;
|
|
228
|
+
}
|
|
229
|
+
const active = await trx
|
|
230
|
+
.selectFrom("provider_connections")
|
|
231
|
+
.select("id")
|
|
232
|
+
.where("workspace_id", "=", workspaceId)
|
|
233
|
+
.where("status", "=", "active")
|
|
234
|
+
.forUpdate()
|
|
235
|
+
.executeTakeFirst();
|
|
236
|
+
if (active) {
|
|
237
|
+
const updated = await trx
|
|
238
|
+
.updateTable("provider_connections")
|
|
239
|
+
.set({
|
|
240
|
+
provider: input.provider,
|
|
241
|
+
external_connection_id: input.externalConnectionId,
|
|
242
|
+
workspace_login: input.workspaceLogin,
|
|
243
|
+
account_type: input.accountType,
|
|
244
|
+
status: "active",
|
|
245
|
+
updated_at: now,
|
|
246
|
+
})
|
|
247
|
+
.where("workspace_id", "=", workspaceId)
|
|
248
|
+
.where("id", "=", active.id)
|
|
249
|
+
.returning("id")
|
|
250
|
+
.executeTakeFirstOrThrow();
|
|
251
|
+
return updated.id;
|
|
252
|
+
}
|
|
253
|
+
const inserted = await trx
|
|
254
|
+
.insertInto("provider_connections")
|
|
255
|
+
.values({
|
|
256
|
+
workspace_id: workspaceId,
|
|
257
|
+
provider: input.provider,
|
|
258
|
+
external_connection_id: input.externalConnectionId,
|
|
259
|
+
workspace_login: input.workspaceLogin,
|
|
260
|
+
account_type: input.accountType,
|
|
261
|
+
status: "active",
|
|
262
|
+
permissions: {},
|
|
263
|
+
})
|
|
264
|
+
.returning("id")
|
|
265
|
+
.executeTakeFirstOrThrow();
|
|
266
|
+
return inserted.id;
|
|
267
|
+
}
|
|
268
|
+
async function upsertRepository(db, workspaceId, providerConnectionId, repository) {
|
|
269
|
+
const now = new Date();
|
|
270
|
+
const row = await db
|
|
271
|
+
.insertInto("repositories")
|
|
272
|
+
.values({
|
|
273
|
+
workspace_id: workspaceId,
|
|
274
|
+
provider: repository.provider,
|
|
275
|
+
provider_connection_id: providerConnectionId,
|
|
276
|
+
external_repository_id: repository.externalRepositoryId,
|
|
277
|
+
owner: repository.owner,
|
|
278
|
+
name: repository.name,
|
|
279
|
+
default_branch: null,
|
|
280
|
+
config_state: "unknown",
|
|
281
|
+
})
|
|
282
|
+
.onConflict((conflict) => conflict.columns(["workspace_id", "provider", "external_repository_id"]).doUpdateSet({
|
|
283
|
+
provider_connection_id: providerConnectionId,
|
|
284
|
+
owner: repository.owner,
|
|
285
|
+
name: repository.name,
|
|
286
|
+
updated_at: now,
|
|
287
|
+
}))
|
|
288
|
+
.returning("id")
|
|
289
|
+
.executeTakeFirstOrThrow();
|
|
290
|
+
return row.id;
|
|
291
|
+
}
|
|
292
|
+
export async function lockProviderConnectionProjection(trx, workspaceId) {
|
|
293
|
+
// Lifecycle projection changes serialize on this workspace lock before taking a provider row lock.
|
|
294
|
+
// Worker authority keeps its canonical job -> absence -> provider row order. Revocation never locks
|
|
295
|
+
// child rows, and deferred cleanup cascades only after no queued/running job references the generation.
|
|
296
|
+
await sql `select pg_advisory_xact_lock(hashtextextended(${workspaceId}, 764737450))`.execute(trx);
|
|
297
|
+
}
|
|
298
|
+
function assertRepositoryProviders(connectionProvider, repositories) {
|
|
299
|
+
if (repositories.some((repository) => repository.provider !== connectionProvider)) {
|
|
300
|
+
throw new Error("repository provider must match its provider connection");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Kysely } from "kysely";
|
|
2
|
+
import type { WorkspaceId } from "@triagepilot/contracts";
|
|
3
|
+
import type { Database } from "./kysely.js";
|
|
4
|
+
export declare const RECEIPT_AND_COMPLETED_JOB_DAYS = 30;
|
|
5
|
+
export declare const DECISION_AND_FAILURE_DAYS = 90;
|
|
6
|
+
export declare function applyFixedRetention(db: Kysely<Database>, workspaceId: WorkspaceId, now: Date): Promise<void>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const RECEIPT_AND_COMPLETED_JOB_DAYS = 30;
|
|
2
|
+
export const DECISION_AND_FAILURE_DAYS = 90;
|
|
3
|
+
export async function applyFixedRetention(db, workspaceId, now) {
|
|
4
|
+
const receiptAndCompletedJobCutoff = daysAgo(now, RECEIPT_AND_COMPLETED_JOB_DAYS);
|
|
5
|
+
const decisionAndFailureCutoff = daysAgo(now, DECISION_AND_FAILURE_DAYS);
|
|
6
|
+
await db.deleteFrom("webhook_receipts").where("workspace_id", "=", workspaceId).where("created_at", "<", receiptAndCompletedJobCutoff).execute();
|
|
7
|
+
await db
|
|
8
|
+
.deleteFrom("jobs")
|
|
9
|
+
.where("workspace_id", "=", workspaceId)
|
|
10
|
+
.where("status", "=", "succeeded")
|
|
11
|
+
.where("updated_at", "<", receiptAndCompletedJobCutoff)
|
|
12
|
+
.execute();
|
|
13
|
+
await db
|
|
14
|
+
.deleteFrom("jobs")
|
|
15
|
+
.where("workspace_id", "=", workspaceId)
|
|
16
|
+
.where("status", "=", "failed")
|
|
17
|
+
.where("updated_at", "<", decisionAndFailureCutoff)
|
|
18
|
+
.execute();
|
|
19
|
+
await db.deleteFrom("routing_decisions")
|
|
20
|
+
.where("workspace_id", "=", workspaceId)
|
|
21
|
+
.where("created_at", "<", decisionAndFailureCutoff)
|
|
22
|
+
.where(({ not, exists, selectFrom }) => not(exists(selectFrom("reviewer_mutation_intents")
|
|
23
|
+
.select("reviewer_mutation_intents.id")
|
|
24
|
+
.whereRef("reviewer_mutation_intents.workspace_id", "=", "routing_decisions.workspace_id")
|
|
25
|
+
.whereRef("reviewer_mutation_intents.decision_id", "=", "routing_decisions.id"))))
|
|
26
|
+
.where(({ not, exists, selectFrom }) => not(exists(selectFrom("reviewer_replacements")
|
|
27
|
+
.select("reviewer_replacements.id")
|
|
28
|
+
.whereRef("reviewer_replacements.workspace_id", "=", "routing_decisions.workspace_id")
|
|
29
|
+
.whereRef("reviewer_replacements.decision_id", "=", "routing_decisions.id"))))
|
|
30
|
+
.where(({ not, exists, selectFrom }) => not(exists(selectFrom("jobs")
|
|
31
|
+
.innerJoin("repositories", (join) => join
|
|
32
|
+
.onRef("repositories.workspace_id", "=", "jobs.workspace_id")
|
|
33
|
+
.onRef("repositories.provider", "=", "jobs.provider")
|
|
34
|
+
.onRef("repositories.provider_connection_id", "=", "jobs.provider_connection_id"))
|
|
35
|
+
.select("jobs.id")
|
|
36
|
+
.whereRef("jobs.workspace_id", "=", "routing_decisions.workspace_id")
|
|
37
|
+
.whereRef("repositories.id", "=", "routing_decisions.repository_id")
|
|
38
|
+
.where("jobs.kind", "=", "activate_reviewer_absence")
|
|
39
|
+
.where("jobs.status", "in", ["queued", "running"]))))
|
|
40
|
+
.execute();
|
|
41
|
+
}
|
|
42
|
+
function daysAgo(now, days) {
|
|
43
|
+
return new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
|
44
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type Kysely } from "kysely";
|
|
2
|
+
import type { ChangeRequestId, ProviderConnectionId, ProviderKind, RepositoryRef, RoutingJobPayload, WorkspaceId } from "@triagepilot/contracts";
|
|
3
|
+
import type { Database } from "./kysely.js";
|
|
4
|
+
export type RoutingRecoveryTargetRequest = {
|
|
5
|
+
decisionId: string;
|
|
6
|
+
} | {
|
|
7
|
+
changeRequest: {
|
|
8
|
+
repository: RepositoryRef;
|
|
9
|
+
externalId: ChangeRequestId;
|
|
10
|
+
number: number;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export interface RoutingRecoveryTarget {
|
|
14
|
+
providerConnectionId: ProviderConnectionId;
|
|
15
|
+
repository: RepositoryRef;
|
|
16
|
+
changeRequestId: ChangeRequestId;
|
|
17
|
+
changeRequestNumber: number;
|
|
18
|
+
}
|
|
19
|
+
export interface RoutingRecoveryEnqueueInput {
|
|
20
|
+
provider: ProviderKind;
|
|
21
|
+
providerConnectionId: ProviderConnectionId;
|
|
22
|
+
payload: RoutingJobPayload;
|
|
23
|
+
idempotencyKey: string;
|
|
24
|
+
}
|
|
25
|
+
export interface WorkspaceRoutingRecoveryRepository {
|
|
26
|
+
findTarget(request: RoutingRecoveryTargetRequest): Promise<RoutingRecoveryTarget | null>;
|
|
27
|
+
findActiveRepository(input: {
|
|
28
|
+
provider: ProviderKind;
|
|
29
|
+
owner: string;
|
|
30
|
+
name: string;
|
|
31
|
+
}): Promise<RepositoryRef | null>;
|
|
32
|
+
findActiveExternalConnectionId(input: {
|
|
33
|
+
provider: ProviderKind;
|
|
34
|
+
providerConnectionId: ProviderConnectionId;
|
|
35
|
+
}): Promise<string | null>;
|
|
36
|
+
enqueue(input: RoutingRecoveryEnqueueInput): Promise<{
|
|
37
|
+
inserted: boolean;
|
|
38
|
+
jobId: string;
|
|
39
|
+
} | null>;
|
|
40
|
+
}
|
|
41
|
+
export declare function createWorkspaceRoutingRecoveryRepository(db: Kysely<Database>, workspaceId: WorkspaceId): WorkspaceRoutingRecoveryRepository;
|
|
42
|
+
export declare function findRoutingRecoveryTarget(db: Kysely<Database>, workspaceId: WorkspaceId, request: RoutingRecoveryTargetRequest): Promise<RoutingRecoveryTarget | null>;
|
|
43
|
+
export declare function findActiveRecoveryRepository(db: Kysely<Database>, workspaceId: WorkspaceId, input: {
|
|
44
|
+
provider: ProviderKind;
|
|
45
|
+
owner: string;
|
|
46
|
+
name: string;
|
|
47
|
+
}): Promise<RepositoryRef | null>;
|
|
48
|
+
export declare function findActiveExternalConnectionId(db: Kysely<Database>, workspaceId: WorkspaceId, input: {
|
|
49
|
+
provider: ProviderKind;
|
|
50
|
+
providerConnectionId: ProviderConnectionId;
|
|
51
|
+
}): Promise<string | null>;
|
|
52
|
+
export declare function enqueueRoutingRecovery(db: Kysely<Database>, workspaceId: WorkspaceId, input: RoutingRecoveryEnqueueInput): Promise<{
|
|
53
|
+
inserted: boolean;
|
|
54
|
+
jobId: string;
|
|
55
|
+
} | null>;
|