@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
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>;