ghagga-db 3.2.0 → 3.4.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/dist/queries.d.ts +386 -2
- package/dist/queries.d.ts.map +1 -1
- package/dist/queries.js +1100 -5
- package/dist/queries.js.map +1 -1
- package/dist/schema.d.ts +887 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +125 -2
- package/dist/schema.js.map +1 -1
- package/package.json +8 -8
package/dist/queries.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { and, asc, desc, eq, gt, inArray, or, sql } from 'drizzle-orm';
|
|
3
|
-
import { DEFAULT_REPO_SETTINGS, githubUserMappings, installationSettings, installations, memoryObservations, memorySessions, repositories, reviews, } from './schema.js';
|
|
2
|
+
import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, lt, or, sql, } from 'drizzle-orm';
|
|
3
|
+
import { DEFAULT_REPO_SETTINGS, explanationInvocations, githubUserMappings, installationSettings, installations, issueDrafts, memoryObservations, memorySessions, repositories, reviews, } from './schema.js';
|
|
4
4
|
// ─── Installations ──────────────────────────────────────────────
|
|
5
5
|
export async function upsertInstallation(db, data) {
|
|
6
6
|
const existing = await db
|
|
@@ -106,13 +106,20 @@ export async function getInstallationById(db, installationId) {
|
|
|
106
106
|
.limit(1);
|
|
107
107
|
return row ?? null;
|
|
108
108
|
}
|
|
109
|
+
function resolveEffectiveRepoSettings(settings) {
|
|
110
|
+
const selectedSettings = (settings ?? DEFAULT_REPO_SETTINGS);
|
|
111
|
+
return {
|
|
112
|
+
...selectedSettings,
|
|
113
|
+
explanationsEnabled: selectedSettings.explanationsEnabled ?? false,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
109
116
|
export async function getEffectiveRepoSettings(db, repo) {
|
|
110
117
|
if (!repo.useGlobalSettings) {
|
|
111
118
|
return {
|
|
112
119
|
providerChain: (repo.providerChain ?? []),
|
|
113
120
|
aiReviewEnabled: repo.aiReviewEnabled,
|
|
114
121
|
reviewMode: repo.reviewMode,
|
|
115
|
-
settings: (repo.settings
|
|
122
|
+
settings: resolveEffectiveRepoSettings(repo.settings),
|
|
116
123
|
source: 'repo',
|
|
117
124
|
};
|
|
118
125
|
}
|
|
@@ -122,7 +129,7 @@ export async function getEffectiveRepoSettings(db, repo) {
|
|
|
122
129
|
providerChain: (globalSettings.providerChain ?? []),
|
|
123
130
|
aiReviewEnabled: globalSettings.aiReviewEnabled,
|
|
124
131
|
reviewMode: globalSettings.reviewMode,
|
|
125
|
-
settings: (globalSettings.settings
|
|
132
|
+
settings: resolveEffectiveRepoSettings(globalSettings.settings),
|
|
126
133
|
source: 'global',
|
|
127
134
|
};
|
|
128
135
|
}
|
|
@@ -131,7 +138,7 @@ export async function getEffectiveRepoSettings(db, repo) {
|
|
|
131
138
|
providerChain: [],
|
|
132
139
|
aiReviewEnabled: true,
|
|
133
140
|
reviewMode: 'simple',
|
|
134
|
-
settings: DEFAULT_REPO_SETTINGS,
|
|
141
|
+
settings: resolveEffectiveRepoSettings(DEFAULT_REPO_SETTINGS),
|
|
135
142
|
source: 'global',
|
|
136
143
|
};
|
|
137
144
|
}
|
|
@@ -222,6 +229,212 @@ export async function saveReview(db, data) {
|
|
|
222
229
|
// biome-ignore lint/style/noNonNullAssertion: drizzle .returning() always returns for insert/update
|
|
223
230
|
return result;
|
|
224
231
|
}
|
|
232
|
+
// ─── Issue Drafts (Issue-Triage Agent) ──────────────────────────
|
|
233
|
+
/**
|
|
234
|
+
* Persist a triage DRAFT produced by the issue-analysis worker.
|
|
235
|
+
*
|
|
236
|
+
* The worker NEVER posts a GitHub comment — it only inserts a DRAFT row that a
|
|
237
|
+
* human later approves in the dashboard. The `issue_drafts` table enforces a
|
|
238
|
+
* partial-unique invariant: AT MOST ONE open DRAFT per (repository, issue)
|
|
239
|
+
* (uq_issue_drafts_open_draft, predicate `status = 'DRAFT'`).
|
|
240
|
+
*
|
|
241
|
+
* Conflict handling (DECISION): we `onConflictDoNothing` on that partial-unique
|
|
242
|
+
* index. If a DRAFT for this (repo, issue) is already open, the insert is a
|
|
243
|
+
* no-op and this returns `undefined` — the caller treats that as "a draft
|
|
244
|
+
* already exists, skip" rather than overwriting an in-flight human review or
|
|
245
|
+
* throwing. Re-triaging is allowed only after the prior draft is decided
|
|
246
|
+
* (APPROVED/REJECTED/POSTED rows fall outside the partial predicate).
|
|
247
|
+
*
|
|
248
|
+
* @returns the inserted row, or `undefined` when an open DRAFT already existed.
|
|
249
|
+
*/
|
|
250
|
+
export async function saveIssueDraft(db, data) {
|
|
251
|
+
const [result] = await db
|
|
252
|
+
.insert(issueDrafts)
|
|
253
|
+
.values(data)
|
|
254
|
+
// Target the partial-unique "one open DRAFT per (repo, issue)" index. The
|
|
255
|
+
// WHERE mirrors the index predicate so Postgres matches the SAME partial
|
|
256
|
+
// index. On conflict: do nothing (skip) — returning() yields no row.
|
|
257
|
+
.onConflictDoNothing({
|
|
258
|
+
target: [issueDrafts.repositoryId, issueDrafts.issueNumber],
|
|
259
|
+
where: sql `${issueDrafts.status} = 'DRAFT'`,
|
|
260
|
+
})
|
|
261
|
+
.returning();
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Fetch the currently-open DRAFT (if any) for a given (repository, issue).
|
|
266
|
+
*
|
|
267
|
+
* Mirrors the partial-unique invariant enforced by `uq_issue_drafts_open_draft`
|
|
268
|
+
* (predicate `status = 'DRAFT'`): at most one open DRAFT exists per (repo,
|
|
269
|
+
* issue), so this returns either that single row or `undefined`.
|
|
270
|
+
*
|
|
271
|
+
* The issue-analysis worker calls this BEFORE the expensive dedup/LLM stages so
|
|
272
|
+
* a retry (or a re-triage while a human is still reviewing the prior draft) can
|
|
273
|
+
* skip early instead of re-charging the LLM and then silently discarding the
|
|
274
|
+
* result at the onConflictDoNothing insert.
|
|
275
|
+
*
|
|
276
|
+
* @returns the open DRAFT row, or `undefined` when none is open.
|
|
277
|
+
*/
|
|
278
|
+
export async function getOpenIssueDraft(db, repositoryId, issueNumber) {
|
|
279
|
+
const [row] = await db
|
|
280
|
+
.select()
|
|
281
|
+
.from(issueDrafts)
|
|
282
|
+
.where(and(eq(issueDrafts.repositoryId, repositoryId), eq(issueDrafts.issueNumber, issueNumber), eq(issueDrafts.status, 'DRAFT')))
|
|
283
|
+
.limit(1);
|
|
284
|
+
return row;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* List issue drafts visible to a set of repositories, newest-first.
|
|
288
|
+
*
|
|
289
|
+
* SCOPING: the caller MUST pass `repositoryIds` already restricted to the
|
|
290
|
+
* authenticated user's repos (see the approval API — it resolves these from
|
|
291
|
+
* `user.installationIds`). This query NEVER widens scope on its own: an empty
|
|
292
|
+
* `repositoryIds` returns an empty list (no rows), so a user with no repos can
|
|
293
|
+
* never see another tenant's drafts. `status` is an optional additional filter.
|
|
294
|
+
*/
|
|
295
|
+
export async function listIssueDrafts(db, repositoryIds, options) {
|
|
296
|
+
if (repositoryIds.length === 0)
|
|
297
|
+
return [];
|
|
298
|
+
const limit = Math.min(options?.limit ?? 50, 100);
|
|
299
|
+
const offset = options?.offset ?? 0;
|
|
300
|
+
const conditions = [inArray(issueDrafts.repositoryId, repositoryIds)];
|
|
301
|
+
if (options?.status) {
|
|
302
|
+
conditions.push(eq(issueDrafts.status, options.status));
|
|
303
|
+
}
|
|
304
|
+
return db
|
|
305
|
+
.select()
|
|
306
|
+
.from(issueDrafts)
|
|
307
|
+
.where(and(...conditions))
|
|
308
|
+
.orderBy(desc(issueDrafts.createdAt))
|
|
309
|
+
.limit(limit)
|
|
310
|
+
.offset(offset);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Fetch a single issue draft by its primary key. No scoping is applied here —
|
|
314
|
+
* the caller MUST verify the row's `repositoryId` belongs to the authenticated
|
|
315
|
+
* user BEFORE returning it or acting on it (the approval API does this).
|
|
316
|
+
*/
|
|
317
|
+
export async function getIssueDraftById(db, id) {
|
|
318
|
+
const [row] = await db.select().from(issueDrafts).where(eq(issueDrafts.id, id)).limit(1);
|
|
319
|
+
return row;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Edit a draft's body (the human sanitizes/edits before posting). Only a row
|
|
323
|
+
* still in DRAFT status is editable — editing an APPROVED/POSTED/REJECTED row is
|
|
324
|
+
* a no-op (returns `undefined`), so a decided draft cannot be silently mutated.
|
|
325
|
+
*/
|
|
326
|
+
export async function updateIssueDraftBody(db, id, body) {
|
|
327
|
+
const [row] = await db
|
|
328
|
+
.update(issueDrafts)
|
|
329
|
+
.set({ body, updatedAt: new Date() })
|
|
330
|
+
.where(and(eq(issueDrafts.id, id), eq(issueDrafts.status, 'DRAFT')))
|
|
331
|
+
.returning();
|
|
332
|
+
return row;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Atomically CLAIM a DRAFT for posting by flipping it to APPROVED.
|
|
336
|
+
*
|
|
337
|
+
* This is the POSTING LOCK that makes the GitHub post exactly-once. The WHERE
|
|
338
|
+
* clause pins `status = 'DRAFT'` and the UPDATE ... RETURNING is a single atomic
|
|
339
|
+
* compare-and-swap (CAS): of N concurrent approvers, EXACTLY ONE matches the row
|
|
340
|
+
* (DRAFT → APPROVED) and gets it back; every other caller matches ZERO rows and
|
|
341
|
+
* gets `undefined`. The winner — and only the winner — proceeds to postComment.
|
|
342
|
+
*
|
|
343
|
+
* APPROVED is therefore a TRANSIENT "posting in progress" state, NOT a terminal
|
|
344
|
+
* decision: it resolves forward to POSTED (markIssueDraftPosted) on a successful
|
|
345
|
+
* post, or back to DRAFT (releaseIssueDraftClaim) when the post fails so a human
|
|
346
|
+
* can retry. The caller MUST treat `undefined` as "another approver already
|
|
347
|
+
* claimed this — return 409 and do NOT post".
|
|
348
|
+
*
|
|
349
|
+
* Lifecycle: DRAFT → APPROVED (posting) → POSTED (or → DRAFT on post failure).
|
|
350
|
+
*/
|
|
351
|
+
export async function claimIssueDraftForPosting(db, id) {
|
|
352
|
+
const [row] = await db
|
|
353
|
+
.update(issueDrafts)
|
|
354
|
+
// claimedAt stamps the posting lease (see findStaleApprovedDrafts): a reaper
|
|
355
|
+
// uses it to detect an APPROVED draft whose poster crashed before posting.
|
|
356
|
+
.set({ status: 'APPROVED', claimedAt: new Date(), updatedAt: new Date() })
|
|
357
|
+
.where(and(eq(issueDrafts.id, id), eq(issueDrafts.status, 'DRAFT')))
|
|
358
|
+
.returning();
|
|
359
|
+
return row;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* RELEASE a posting claim by reverting APPROVED → DRAFT after a FAILED post.
|
|
363
|
+
*
|
|
364
|
+
* The CAS pins `status = 'APPROVED'` so this only ever un-does an in-flight
|
|
365
|
+
* posting claim (never a POSTED/REJECTED terminal row). After release the draft
|
|
366
|
+
* is editable/approvable again, so the human can retry. Returns the reverted row,
|
|
367
|
+
* or `undefined` when the row was no longer APPROVED (defensive — should not
|
|
368
|
+
* happen on the post-failure path since the claimer owns the APPROVED row).
|
|
369
|
+
*/
|
|
370
|
+
export async function releaseIssueDraftClaim(db, id) {
|
|
371
|
+
const [row] = await db
|
|
372
|
+
.update(issueDrafts)
|
|
373
|
+
// Clear the posting lease so a subsequent re-claim gets a fresh claimedAt
|
|
374
|
+
// (a stale lease would otherwise make the re-claimed draft look orphaned).
|
|
375
|
+
.set({ status: 'DRAFT', claimedAt: null, updatedAt: new Date() })
|
|
376
|
+
.where(and(eq(issueDrafts.id, id), eq(issueDrafts.status, 'APPROVED')))
|
|
377
|
+
.returning();
|
|
378
|
+
return row;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Atomically transition an APPROVED (claimed-for-posting) draft to POSTED,
|
|
382
|
+
* recording the GitHub comment id.
|
|
383
|
+
*
|
|
384
|
+
* IDEMPOTENCY/LIFECYCLE: the WHERE clause pins `status = 'APPROVED'` — only the
|
|
385
|
+
* approver that WON the posting claim (claimIssueDraftForPosting) and actually
|
|
386
|
+
* posted the comment reaches this. A row that is not APPROVED matches ZERO rows
|
|
387
|
+
* and returns `undefined`. Combined with the claim CAS this guarantees the post
|
|
388
|
+
* is recorded exactly once. The caller MUST treat `undefined` as "already
|
|
389
|
+
* decided, do not pretend we re-posted".
|
|
390
|
+
*/
|
|
391
|
+
export async function markIssueDraftPosted(db, id, postedCommentId) {
|
|
392
|
+
const [row] = await db
|
|
393
|
+
.update(issueDrafts)
|
|
394
|
+
.set({ status: 'POSTED', postedCommentId, updatedAt: new Date() })
|
|
395
|
+
.where(and(eq(issueDrafts.id, id), eq(issueDrafts.status, 'APPROVED')))
|
|
396
|
+
.returning();
|
|
397
|
+
return row;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Transition a DRAFT to REJECTED — no comment is ever posted. Same DRAFT-only
|
|
401
|
+
* guard as the post transition: a decided row is not re-rejected (returns
|
|
402
|
+
* `undefined`).
|
|
403
|
+
*/
|
|
404
|
+
export async function rejectIssueDraft(db, id) {
|
|
405
|
+
const [row] = await db
|
|
406
|
+
.update(issueDrafts)
|
|
407
|
+
.set({ status: 'REJECTED', updatedAt: new Date() })
|
|
408
|
+
.where(and(eq(issueDrafts.id, id), eq(issueDrafts.status, 'DRAFT')))
|
|
409
|
+
.returning();
|
|
410
|
+
return row;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* DETECT stuck/orphaned APPROVED drafts whose posting lease is older than
|
|
414
|
+
* `olderThan` — the reaper-detection primitive (RES-001).
|
|
415
|
+
*
|
|
416
|
+
* APPROVED is a TRANSIENT "posting in progress" state (see
|
|
417
|
+
* claimIssueDraftForPosting). If the poster process crashes AFTER claiming
|
|
418
|
+
* (DRAFT → APPROVED, stamping `claimedAt`) but BEFORE it reaches POSTED or
|
|
419
|
+
* releases back to DRAFT, the row is stuck in APPROVED forever with no signal.
|
|
420
|
+
* This query surfaces those rows: status = APPROVED AND claimedAt IS NOT NULL
|
|
421
|
+
* AND claimedAt < olderThan, oldest-lease-first, capped at 100.
|
|
422
|
+
*
|
|
423
|
+
* It ONLY DETECTS — it takes no recovery action. The caller (PR2's reaper
|
|
424
|
+
* worker) decides what to do with each returned row: release it back to DRAFT
|
|
425
|
+
* via `releaseIssueDraftClaim` so a human can retry, or alert an operator.
|
|
426
|
+
*
|
|
427
|
+
* Global (not tenant-scoped): the reaper is a system maintenance job over the
|
|
428
|
+
* whole table, not a user-facing listing.
|
|
429
|
+
*/
|
|
430
|
+
export async function findStaleApprovedDrafts(db, olderThan) {
|
|
431
|
+
return db
|
|
432
|
+
.select()
|
|
433
|
+
.from(issueDrafts)
|
|
434
|
+
.where(and(eq(issueDrafts.status, 'APPROVED'), isNotNull(issueDrafts.claimedAt), lt(issueDrafts.claimedAt, olderThan)))
|
|
435
|
+
.orderBy(asc(issueDrafts.claimedAt))
|
|
436
|
+
.limit(100);
|
|
437
|
+
}
|
|
225
438
|
export async function getReviewsByRepoId(db, repositoryId, options = {}) {
|
|
226
439
|
const { limit = 50, offset = 0 } = options;
|
|
227
440
|
return db
|
|
@@ -1081,4 +1294,886 @@ export async function clearEmptyMemorySessions(db, installationId, project) {
|
|
|
1081
1294
|
.returning({ id: memorySessions.id });
|
|
1082
1295
|
return { deletedCount: result.length };
|
|
1083
1296
|
}
|
|
1297
|
+
const explanationIdentityTextFields = [
|
|
1298
|
+
'forgeInstance',
|
|
1299
|
+
'installationId',
|
|
1300
|
+
'actorId',
|
|
1301
|
+
'repositoryId',
|
|
1302
|
+
'requestedHeadSha',
|
|
1303
|
+
'sourceCommentId',
|
|
1304
|
+
];
|
|
1305
|
+
/**
|
|
1306
|
+
* Validates only the immutable identity shape. Opaque forge values are preserved
|
|
1307
|
+
* byte-for-byte; especially, questionHash is never trimmed or case-folded.
|
|
1308
|
+
*/
|
|
1309
|
+
export function isValidExplanationInvocationIdentity(identity) {
|
|
1310
|
+
if (typeof identity !== 'object' || identity === null)
|
|
1311
|
+
return false;
|
|
1312
|
+
const candidate = identity;
|
|
1313
|
+
const pullRequestNumber = candidate.pullRequestNumber;
|
|
1314
|
+
if (typeof pullRequestNumber !== 'number' ||
|
|
1315
|
+
!Number.isSafeInteger(pullRequestNumber) ||
|
|
1316
|
+
pullRequestNumber <= 0)
|
|
1317
|
+
return false;
|
|
1318
|
+
if (typeof candidate.questionHash !== 'string' || !/^[a-f0-9]{64}$/.test(candidate.questionHash))
|
|
1319
|
+
return false;
|
|
1320
|
+
return explanationIdentityTextFields.every((field) => typeof candidate[field] === 'string' && candidate[field].length > 0);
|
|
1321
|
+
}
|
|
1322
|
+
export function isValidExplanationInvocationRequest(request) {
|
|
1323
|
+
if (!isValidExplanationInvocationIdentity(request))
|
|
1324
|
+
return false;
|
|
1325
|
+
const candidate = request;
|
|
1326
|
+
return (typeof candidate.question === 'string' &&
|
|
1327
|
+
candidate.question.length > 0 &&
|
|
1328
|
+
createHash('sha256').update(candidate.question).digest('hex') === candidate.questionHash);
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Produces an unambiguous SHA-256 key from the complete immutable tuple.
|
|
1332
|
+
* JSON array encoding prevents delimiter ambiguity between opaque identifiers.
|
|
1333
|
+
*/
|
|
1334
|
+
export function deriveExplanationInvocationKey(identity) {
|
|
1335
|
+
if (!isValidExplanationInvocationIdentity(identity))
|
|
1336
|
+
return null;
|
|
1337
|
+
return createHash('sha256')
|
|
1338
|
+
.update(JSON.stringify([
|
|
1339
|
+
'explanation-invocation-v1',
|
|
1340
|
+
identity.forgeInstance,
|
|
1341
|
+
identity.installationId,
|
|
1342
|
+
identity.actorId,
|
|
1343
|
+
identity.repositoryId,
|
|
1344
|
+
identity.pullRequestNumber,
|
|
1345
|
+
identity.requestedHeadSha,
|
|
1346
|
+
identity.sourceCommentId,
|
|
1347
|
+
identity.questionHash,
|
|
1348
|
+
]))
|
|
1349
|
+
.digest('hex');
|
|
1350
|
+
}
|
|
1351
|
+
function matchesExplanationIdentity(row, identity) {
|
|
1352
|
+
return (row.forgeInstance === identity.forgeInstance &&
|
|
1353
|
+
row.installationId === identity.installationId &&
|
|
1354
|
+
row.actorId === identity.actorId &&
|
|
1355
|
+
row.repositoryId === identity.repositoryId &&
|
|
1356
|
+
row.pullRequestNumber === identity.pullRequestNumber &&
|
|
1357
|
+
row.requestedHeadSha === identity.requestedHeadSha &&
|
|
1358
|
+
row.sourceCommentId === identity.sourceCommentId &&
|
|
1359
|
+
row.questionHash === identity.questionHash &&
|
|
1360
|
+
row.question === identity.question);
|
|
1361
|
+
}
|
|
1362
|
+
async function selectExplanationInvocationByKey(db, invocationKey) {
|
|
1363
|
+
const [row] = await db
|
|
1364
|
+
.select()
|
|
1365
|
+
.from(explanationInvocations)
|
|
1366
|
+
.where(eq(explanationInvocations.invocationKey, invocationKey))
|
|
1367
|
+
.limit(1);
|
|
1368
|
+
return row;
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Inserts the sole pending claim, then separately observes a conflict. The
|
|
1372
|
+
* conflict follow-up is mandatory: a missing row is unavailable, never permission
|
|
1373
|
+
* to dispatch. This function creates no reservation or lifecycle authority.
|
|
1374
|
+
*/
|
|
1375
|
+
export async function registerExplanationInvocation(db, request) {
|
|
1376
|
+
if (!isValidExplanationInvocationRequest(request))
|
|
1377
|
+
return { status: 'invalid' };
|
|
1378
|
+
const invocationKey = deriveExplanationInvocationKey(request);
|
|
1379
|
+
if (invocationKey === null)
|
|
1380
|
+
return { status: 'invalid' };
|
|
1381
|
+
const [inserted] = await db
|
|
1382
|
+
.insert(explanationInvocations)
|
|
1383
|
+
.values({
|
|
1384
|
+
invocationKey,
|
|
1385
|
+
forgeInstance: request.forgeInstance,
|
|
1386
|
+
installationId: request.installationId,
|
|
1387
|
+
actorId: request.actorId,
|
|
1388
|
+
repositoryId: request.repositoryId,
|
|
1389
|
+
pullRequestNumber: request.pullRequestNumber,
|
|
1390
|
+
requestedHeadSha: request.requestedHeadSha,
|
|
1391
|
+
sourceCommentId: request.sourceCommentId,
|
|
1392
|
+
questionHash: request.questionHash,
|
|
1393
|
+
question: request.question,
|
|
1394
|
+
})
|
|
1395
|
+
.onConflictDoNothing({ target: explanationInvocations.invocationKey })
|
|
1396
|
+
.returning();
|
|
1397
|
+
if (inserted)
|
|
1398
|
+
return { status: 'registered', invocation: inserted };
|
|
1399
|
+
const observed = await selectExplanationInvocationByKey(db, invocationKey);
|
|
1400
|
+
if (!observed)
|
|
1401
|
+
return { status: 'unavailable' };
|
|
1402
|
+
if (!matchesExplanationIdentity(observed, request))
|
|
1403
|
+
return { status: 'mismatch' };
|
|
1404
|
+
return { status: 'duplicate', invocation: observed };
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Fetches an invocation only when the caller supplies its complete expected
|
|
1408
|
+
* identity. A derived-key collision or changed tuple is deliberately opaque.
|
|
1409
|
+
*/
|
|
1410
|
+
export async function lookupExplanationInvocation(db, request) {
|
|
1411
|
+
if (!isValidExplanationInvocationRequest(request))
|
|
1412
|
+
return { status: 'invalid' };
|
|
1413
|
+
const invocationKey = deriveExplanationInvocationKey(request);
|
|
1414
|
+
if (invocationKey === null)
|
|
1415
|
+
return { status: 'invalid' };
|
|
1416
|
+
const observed = await selectExplanationInvocationByKey(db, invocationKey);
|
|
1417
|
+
if (!observed)
|
|
1418
|
+
return { status: 'not_found' };
|
|
1419
|
+
if (!matchesExplanationIdentity(observed, request))
|
|
1420
|
+
return { status: 'mismatch' };
|
|
1421
|
+
return { status: 'found', invocation: observed };
|
|
1422
|
+
}
|
|
1423
|
+
const MIN_EXPLANATION_DISPATCH_LEASE_MS = 1_000;
|
|
1424
|
+
const MAX_EXPLANATION_DISPATCH_LEASE_MS = 300_000;
|
|
1425
|
+
function isValidExplanationInvocationDispatchReservationRequest(value) {
|
|
1426
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
1427
|
+
return false;
|
|
1428
|
+
const request = value;
|
|
1429
|
+
return (hasOnlyFields(request, [
|
|
1430
|
+
'forgeInstance',
|
|
1431
|
+
'installationId',
|
|
1432
|
+
'actorId',
|
|
1433
|
+
'repositoryId',
|
|
1434
|
+
'pullRequestNumber',
|
|
1435
|
+
'requestedHeadSha',
|
|
1436
|
+
'sourceCommentId',
|
|
1437
|
+
'questionHash',
|
|
1438
|
+
'question',
|
|
1439
|
+
'leaseDurationMs',
|
|
1440
|
+
]) &&
|
|
1441
|
+
isValidExplanationInvocationRequest(request) &&
|
|
1442
|
+
typeof request.leaseDurationMs === 'number' &&
|
|
1443
|
+
Number.isSafeInteger(request.leaseDurationMs) &&
|
|
1444
|
+
request.leaseDurationMs >= MIN_EXPLANATION_DISPATCH_LEASE_MS &&
|
|
1445
|
+
request.leaseDurationMs <= MAX_EXPLANATION_DISPATCH_LEASE_MS);
|
|
1446
|
+
}
|
|
1447
|
+
/** A returned reservation authorizes only post-commit external I/O; this function never commits a caller transaction. */
|
|
1448
|
+
export async function reserveExplanationInvocationDispatch(db, value) {
|
|
1449
|
+
if (!isValidExplanationInvocationDispatchReservationRequest(value))
|
|
1450
|
+
return { status: 'invalid' };
|
|
1451
|
+
const invocationKey = deriveExplanationInvocationKey(value);
|
|
1452
|
+
if (invocationKey === null)
|
|
1453
|
+
return { status: 'invalid' };
|
|
1454
|
+
const [reserved] = await db
|
|
1455
|
+
.update(explanationInvocations)
|
|
1456
|
+
.set({
|
|
1457
|
+
executionStatus: 'DISPATCH_RESERVED',
|
|
1458
|
+
executionFence: sql `gen_random_uuid()::text`,
|
|
1459
|
+
dispatchReservedAt: sql `clock_timestamp()`,
|
|
1460
|
+
dispatchLeaseExpiresAt: sql `clock_timestamp() + (${value.leaseDurationMs} * interval '1 millisecond')`,
|
|
1461
|
+
})
|
|
1462
|
+
.where(and(eq(explanationInvocations.invocationKey, invocationKey), eq(explanationInvocations.forgeInstance, value.forgeInstance), eq(explanationInvocations.installationId, value.installationId), eq(explanationInvocations.actorId, value.actorId), eq(explanationInvocations.repositoryId, value.repositoryId), eq(explanationInvocations.pullRequestNumber, value.pullRequestNumber), eq(explanationInvocations.requestedHeadSha, value.requestedHeadSha), eq(explanationInvocations.sourceCommentId, value.sourceCommentId), eq(explanationInvocations.questionHash, value.questionHash), eq(explanationInvocations.question, value.question), eq(explanationInvocations.executionStatus, 'PENDING'), isNull(explanationInvocations.executionFence), isNull(explanationInvocations.dispatchReservedAt), isNull(explanationInvocations.dispatchLeaseExpiresAt), isNull(explanationInvocations.outcomeStatus), isNull(explanationInvocations.outcomeAnswer), isNull(explanationInvocations.outcomePayload), isNull(explanationInvocations.outcomeCompletedAt)))
|
|
1463
|
+
.returning();
|
|
1464
|
+
if (reserved && isNonEmptyText(reserved.executionFence)) {
|
|
1465
|
+
return { status: 'reserved', invocation: reserved, executionFence: reserved.executionFence };
|
|
1466
|
+
}
|
|
1467
|
+
const [observed] = await db
|
|
1468
|
+
.select()
|
|
1469
|
+
.from(explanationInvocations)
|
|
1470
|
+
.where(and(eq(explanationInvocations.invocationKey, invocationKey), eq(explanationInvocations.question, value.question)))
|
|
1471
|
+
.limit(1);
|
|
1472
|
+
return observed && matchesExplanationIdentity(observed, value)
|
|
1473
|
+
? { status: 'observed', invocation: observed }
|
|
1474
|
+
: { status: 'unavailable' };
|
|
1475
|
+
}
|
|
1476
|
+
function isValidExplanationInvocationExpiredDispatchRecoveryRequest(value) {
|
|
1477
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
1478
|
+
return false;
|
|
1479
|
+
const request = value;
|
|
1480
|
+
return (hasOnlyFields(request, [
|
|
1481
|
+
'forgeInstance',
|
|
1482
|
+
'installationId',
|
|
1483
|
+
'actorId',
|
|
1484
|
+
'repositoryId',
|
|
1485
|
+
'pullRequestNumber',
|
|
1486
|
+
'requestedHeadSha',
|
|
1487
|
+
'sourceCommentId',
|
|
1488
|
+
'questionHash',
|
|
1489
|
+
'question',
|
|
1490
|
+
'executionFence',
|
|
1491
|
+
]) &&
|
|
1492
|
+
isValidExplanationInvocationRequest(request) &&
|
|
1493
|
+
isNonEmptyText(request.executionFence));
|
|
1494
|
+
}
|
|
1495
|
+
export async function recoverExpiredExplanationInvocationDispatch(db, value) {
|
|
1496
|
+
if (!isValidExplanationInvocationExpiredDispatchRecoveryRequest(value))
|
|
1497
|
+
return { status: 'invalid' };
|
|
1498
|
+
const invocationKey = deriveExplanationInvocationKey(value);
|
|
1499
|
+
if (invocationKey === null)
|
|
1500
|
+
return { status: 'invalid' };
|
|
1501
|
+
const conditions = [
|
|
1502
|
+
eq(explanationInvocations.invocationKey, invocationKey),
|
|
1503
|
+
eq(explanationInvocations.forgeInstance, value.forgeInstance),
|
|
1504
|
+
eq(explanationInvocations.installationId, value.installationId),
|
|
1505
|
+
eq(explanationInvocations.actorId, value.actorId),
|
|
1506
|
+
eq(explanationInvocations.repositoryId, value.repositoryId),
|
|
1507
|
+
eq(explanationInvocations.pullRequestNumber, value.pullRequestNumber),
|
|
1508
|
+
eq(explanationInvocations.requestedHeadSha, value.requestedHeadSha),
|
|
1509
|
+
eq(explanationInvocations.sourceCommentId, value.sourceCommentId),
|
|
1510
|
+
eq(explanationInvocations.questionHash, value.questionHash),
|
|
1511
|
+
eq(explanationInvocations.question, value.question),
|
|
1512
|
+
eq(explanationInvocations.executionStatus, 'DISPATCH_RESERVED'),
|
|
1513
|
+
eq(explanationInvocations.executionFence, value.executionFence),
|
|
1514
|
+
isNull(explanationInvocations.outcomeStatus),
|
|
1515
|
+
isNull(explanationInvocations.outcomeAnswer),
|
|
1516
|
+
isNull(explanationInvocations.outcomePayload),
|
|
1517
|
+
isNull(explanationInvocations.outcomeCompletedAt),
|
|
1518
|
+
];
|
|
1519
|
+
const locked = db.$with('locked_expired_explanation_invocation').as(db
|
|
1520
|
+
.select({
|
|
1521
|
+
id: explanationInvocations.id,
|
|
1522
|
+
lease: explanationInvocations.dispatchLeaseExpiresAt,
|
|
1523
|
+
})
|
|
1524
|
+
.from(explanationInvocations)
|
|
1525
|
+
.where(and(...conditions))
|
|
1526
|
+
.for('update'));
|
|
1527
|
+
const outcome = {
|
|
1528
|
+
kind: 'AMBIGUOUS',
|
|
1529
|
+
reason: 'dispatch lease expired before durable completion',
|
|
1530
|
+
};
|
|
1531
|
+
const [recovered] = await db
|
|
1532
|
+
.with(locked)
|
|
1533
|
+
.update(explanationInvocations)
|
|
1534
|
+
.set({
|
|
1535
|
+
executionStatus: 'AMBIGUOUS',
|
|
1536
|
+
outcomeStatus: 'AMBIGUOUS',
|
|
1537
|
+
outcomeAnswer: null,
|
|
1538
|
+
outcomePayload: outcome,
|
|
1539
|
+
outcomeCompletedAt: sql `clock_timestamp()`,
|
|
1540
|
+
})
|
|
1541
|
+
.from(locked)
|
|
1542
|
+
.where(and(eq(explanationInvocations.id, locked.id), lt(locked.lease, sql `clock_timestamp()`)))
|
|
1543
|
+
.returning();
|
|
1544
|
+
return recovered ? { status: 'recovered', invocation: recovered } : { status: 'unavailable' };
|
|
1545
|
+
}
|
|
1546
|
+
// ─── Explanation Invocation Settlement ──────────────────────────
|
|
1547
|
+
export const EXPLANATION_TERMINAL_OUTCOME_KINDS = [
|
|
1548
|
+
'ANSWERED',
|
|
1549
|
+
'INVALID',
|
|
1550
|
+
'UNAUTHORIZED',
|
|
1551
|
+
'DISABLED',
|
|
1552
|
+
'STALE',
|
|
1553
|
+
'AI_UNAVAILABLE',
|
|
1554
|
+
'AMBIGUOUS',
|
|
1555
|
+
];
|
|
1556
|
+
const pendingTerminalOutcomeKinds = [
|
|
1557
|
+
'INVALID',
|
|
1558
|
+
'UNAUTHORIZED',
|
|
1559
|
+
'DISABLED',
|
|
1560
|
+
'STALE',
|
|
1561
|
+
'AI_UNAVAILABLE',
|
|
1562
|
+
];
|
|
1563
|
+
function isNonEmptyText(value) {
|
|
1564
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
1565
|
+
}
|
|
1566
|
+
function hasOnlyFields(value, fields) {
|
|
1567
|
+
return Object.keys(value).every((field) => fields.includes(field));
|
|
1568
|
+
}
|
|
1569
|
+
function isValidExplanationOutcomePayload(value) {
|
|
1570
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
1571
|
+
return false;
|
|
1572
|
+
const payload = value;
|
|
1573
|
+
if (!EXPLANATION_TERMINAL_OUTCOME_KINDS.includes(payload.kind)) {
|
|
1574
|
+
return false;
|
|
1575
|
+
}
|
|
1576
|
+
if (payload.kind === 'ANSWERED') {
|
|
1577
|
+
if (!hasOnlyFields(payload, ['kind', 'answer', 'metadata']) ||
|
|
1578
|
+
!isNonEmptyText(payload.answer)) {
|
|
1579
|
+
return false;
|
|
1580
|
+
}
|
|
1581
|
+
if (typeof payload.metadata !== 'object' ||
|
|
1582
|
+
payload.metadata === null ||
|
|
1583
|
+
Array.isArray(payload.metadata)) {
|
|
1584
|
+
return false;
|
|
1585
|
+
}
|
|
1586
|
+
const metadata = payload.metadata;
|
|
1587
|
+
return (hasOnlyFields(metadata, ['provider', 'model', 'tokensUsed']) &&
|
|
1588
|
+
isNonEmptyText(metadata.provider) &&
|
|
1589
|
+
isNonEmptyText(metadata.model) &&
|
|
1590
|
+
typeof metadata.tokensUsed === 'number' &&
|
|
1591
|
+
Number.isFinite(metadata.tokensUsed) &&
|
|
1592
|
+
Number.isSafeInteger(metadata.tokensUsed) &&
|
|
1593
|
+
metadata.tokensUsed >= 0);
|
|
1594
|
+
}
|
|
1595
|
+
return hasOnlyFields(payload, ['kind', 'reason']) && isNonEmptyText(payload.reason);
|
|
1596
|
+
}
|
|
1597
|
+
/** Validates the complete immutable settlement request before any database access. */
|
|
1598
|
+
export function validateExplanationInvocationSettlement(value) {
|
|
1599
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
1600
|
+
return null;
|
|
1601
|
+
const request = value;
|
|
1602
|
+
if (!hasOnlyFields(request, [
|
|
1603
|
+
'forgeInstance',
|
|
1604
|
+
'installationId',
|
|
1605
|
+
'actorId',
|
|
1606
|
+
'repositoryId',
|
|
1607
|
+
'pullRequestNumber',
|
|
1608
|
+
'requestedHeadSha',
|
|
1609
|
+
'sourceCommentId',
|
|
1610
|
+
'questionHash',
|
|
1611
|
+
'question',
|
|
1612
|
+
'expectedExecutionStatus',
|
|
1613
|
+
'executionFence',
|
|
1614
|
+
'outcome',
|
|
1615
|
+
]) ||
|
|
1616
|
+
!isValidExplanationInvocationRequest(request) ||
|
|
1617
|
+
!isValidExplanationOutcomePayload(request.outcome) ||
|
|
1618
|
+
(request.expectedExecutionStatus !== 'PENDING' &&
|
|
1619
|
+
request.expectedExecutionStatus !== 'DISPATCH_RESERVED')) {
|
|
1620
|
+
return null;
|
|
1621
|
+
}
|
|
1622
|
+
const outcome = request.outcome;
|
|
1623
|
+
if (request.expectedExecutionStatus === 'PENDING') {
|
|
1624
|
+
if (request.executionFence !== undefined ||
|
|
1625
|
+
!pendingTerminalOutcomeKinds.some((kind) => kind === outcome.kind)) {
|
|
1626
|
+
return null;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
else if (!isNonEmptyText(request.executionFence) ||
|
|
1630
|
+
!['ANSWERED', 'AI_UNAVAILABLE', 'AMBIGUOUS'].includes(outcome.kind)) {
|
|
1631
|
+
return null;
|
|
1632
|
+
}
|
|
1633
|
+
return value;
|
|
1634
|
+
}
|
|
1635
|
+
function outcomeAnswer(payload) {
|
|
1636
|
+
return payload.kind === 'ANSWERED' ? payload.answer : null;
|
|
1637
|
+
}
|
|
1638
|
+
function isCompleteExplanationInvocationOutcome(row) {
|
|
1639
|
+
if (row.outcomeStatus !== row.executionStatus ||
|
|
1640
|
+
row.outcomeCompletedAt === null ||
|
|
1641
|
+
!isValidExplanationOutcomePayload(row.outcomePayload) ||
|
|
1642
|
+
row.outcomePayload.kind !== row.executionStatus) {
|
|
1643
|
+
return false;
|
|
1644
|
+
}
|
|
1645
|
+
return row.outcomeAnswer === outcomeAnswer(row.outcomePayload);
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Performs one fenced conditional update. A zero-row result is observation-only:
|
|
1649
|
+
* it grants no execution authority and never repairs legacy rows.
|
|
1650
|
+
*/
|
|
1651
|
+
export async function settleExplanationInvocation(db, value) {
|
|
1652
|
+
const request = validateExplanationInvocationSettlement(value);
|
|
1653
|
+
if (!request)
|
|
1654
|
+
return { status: 'invalid' };
|
|
1655
|
+
const invocationKey = deriveExplanationInvocationKey(request);
|
|
1656
|
+
if (invocationKey === null)
|
|
1657
|
+
return { status: 'invalid' };
|
|
1658
|
+
const conditions = [
|
|
1659
|
+
eq(explanationInvocations.invocationKey, invocationKey),
|
|
1660
|
+
eq(explanationInvocations.forgeInstance, request.forgeInstance),
|
|
1661
|
+
eq(explanationInvocations.installationId, request.installationId),
|
|
1662
|
+
eq(explanationInvocations.actorId, request.actorId),
|
|
1663
|
+
eq(explanationInvocations.repositoryId, request.repositoryId),
|
|
1664
|
+
eq(explanationInvocations.pullRequestNumber, request.pullRequestNumber),
|
|
1665
|
+
eq(explanationInvocations.requestedHeadSha, request.requestedHeadSha),
|
|
1666
|
+
eq(explanationInvocations.sourceCommentId, request.sourceCommentId),
|
|
1667
|
+
eq(explanationInvocations.questionHash, request.questionHash),
|
|
1668
|
+
eq(explanationInvocations.question, request.question),
|
|
1669
|
+
eq(explanationInvocations.executionStatus, request.expectedExecutionStatus),
|
|
1670
|
+
sql `${explanationInvocations.outcomeStatus} IS NULL`,
|
|
1671
|
+
sql `${explanationInvocations.outcomeAnswer} IS NULL`,
|
|
1672
|
+
sql `${explanationInvocations.outcomePayload} IS NULL`,
|
|
1673
|
+
sql `${explanationInvocations.outcomeCompletedAt} IS NULL`,
|
|
1674
|
+
];
|
|
1675
|
+
const postLockConditions = [];
|
|
1676
|
+
const executionFence = request.executionFence;
|
|
1677
|
+
if (request.expectedExecutionStatus === 'PENDING') {
|
|
1678
|
+
conditions.push(isNull(explanationInvocations.executionFence), isNull(explanationInvocations.dispatchReservedAt), isNull(explanationInvocations.dispatchLeaseExpiresAt));
|
|
1679
|
+
}
|
|
1680
|
+
else {
|
|
1681
|
+
if (executionFence === undefined)
|
|
1682
|
+
return { status: 'invalid' };
|
|
1683
|
+
conditions.push(eq(explanationInvocations.executionFence, executionFence));
|
|
1684
|
+
if (request.outcome.kind !== 'AMBIGUOUS') {
|
|
1685
|
+
postLockConditions.push(gt(explanationInvocations.dispatchLeaseExpiresAt, sql `clock_timestamp()`));
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
const locked = db
|
|
1689
|
+
.select({
|
|
1690
|
+
id: explanationInvocations.id,
|
|
1691
|
+
dispatchLeaseExpiresAt: explanationInvocations.dispatchLeaseExpiresAt,
|
|
1692
|
+
})
|
|
1693
|
+
.from(explanationInvocations)
|
|
1694
|
+
.where(and(...conditions))
|
|
1695
|
+
.for('update')
|
|
1696
|
+
.as('locked_explanation_invocation');
|
|
1697
|
+
const [settled] = await db
|
|
1698
|
+
.update(explanationInvocations)
|
|
1699
|
+
.set({
|
|
1700
|
+
executionStatus: request.outcome.kind,
|
|
1701
|
+
outcomeStatus: request.outcome.kind,
|
|
1702
|
+
outcomeAnswer: outcomeAnswer(request.outcome),
|
|
1703
|
+
outcomePayload: request.outcome,
|
|
1704
|
+
outcomeCompletedAt: sql `now()`,
|
|
1705
|
+
})
|
|
1706
|
+
.from(locked)
|
|
1707
|
+
.where(and(eq(explanationInvocations.id, locked.id), ...postLockConditions))
|
|
1708
|
+
.returning();
|
|
1709
|
+
if (settled)
|
|
1710
|
+
return { status: 'settled', invocation: settled };
|
|
1711
|
+
const [observed] = await db
|
|
1712
|
+
.select()
|
|
1713
|
+
.from(explanationInvocations)
|
|
1714
|
+
.where(and(eq(explanationInvocations.invocationKey, invocationKey), eq(explanationInvocations.forgeInstance, request.forgeInstance), eq(explanationInvocations.installationId, request.installationId), eq(explanationInvocations.actorId, request.actorId), eq(explanationInvocations.repositoryId, request.repositoryId), eq(explanationInvocations.pullRequestNumber, request.pullRequestNumber), eq(explanationInvocations.requestedHeadSha, request.requestedHeadSha), eq(explanationInvocations.sourceCommentId, request.sourceCommentId), eq(explanationInvocations.questionHash, request.questionHash), eq(explanationInvocations.question, request.question), inArray(explanationInvocations.executionStatus, EXPLANATION_TERMINAL_OUTCOME_KINDS), isNotNull(explanationInvocations.outcomeStatus), isNotNull(explanationInvocations.outcomePayload), isNotNull(explanationInvocations.outcomeCompletedAt)))
|
|
1715
|
+
.limit(1);
|
|
1716
|
+
return observed && isCompleteExplanationInvocationOutcome(observed)
|
|
1717
|
+
? { status: 'settled', invocation: observed }
|
|
1718
|
+
: { status: 'unavailable' };
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Bounded ingress recovery: convert a claim whose queue admission is unknown
|
|
1722
|
+
* into an explicit terminal ambiguity. The identity and PENDING CAS make this
|
|
1723
|
+
* safe to call repeatedly and prevent a later worker from dispatching it.
|
|
1724
|
+
*/
|
|
1725
|
+
export async function recoverPendingExplanationInvocation(db, value) {
|
|
1726
|
+
if (!isValidExplanationInvocationRequest(value))
|
|
1727
|
+
return { status: 'invalid' };
|
|
1728
|
+
const invocationKey = deriveExplanationInvocationKey(value);
|
|
1729
|
+
if (invocationKey === null)
|
|
1730
|
+
return { status: 'invalid' };
|
|
1731
|
+
const outcome = {
|
|
1732
|
+
kind: 'AMBIGUOUS',
|
|
1733
|
+
reason: 'Explanation queue admission could not be confirmed.',
|
|
1734
|
+
};
|
|
1735
|
+
const [settled] = await db
|
|
1736
|
+
.update(explanationInvocations)
|
|
1737
|
+
.set({
|
|
1738
|
+
executionStatus: 'AMBIGUOUS',
|
|
1739
|
+
outcomeStatus: 'AMBIGUOUS',
|
|
1740
|
+
outcomeAnswer: null,
|
|
1741
|
+
outcomePayload: outcome,
|
|
1742
|
+
outcomeCompletedAt: sql `now()`,
|
|
1743
|
+
})
|
|
1744
|
+
.where(and(...explanationPublicationIdentityConditions(invocationKey, value), eq(explanationInvocations.executionStatus, 'PENDING'), isNull(explanationInvocations.executionFence), isNull(explanationInvocations.outcomeStatus), isNull(explanationInvocations.outcomePayload), isNull(explanationInvocations.outcomeCompletedAt)))
|
|
1745
|
+
.returning();
|
|
1746
|
+
return settled ? { status: 'settled', invocation: settled } : { status: 'unavailable' };
|
|
1747
|
+
}
|
|
1748
|
+
// ─── Explanation Publication CREATE ─────────────────────────────
|
|
1749
|
+
export const EXPLANATION_PUBLICATION_CHANNELS = {
|
|
1750
|
+
PROGRESS: 'progress',
|
|
1751
|
+
ANSWER: 'answer',
|
|
1752
|
+
};
|
|
1753
|
+
export const EXPLANATION_PUBLICATION_CREATE_OUTCOMES = {
|
|
1754
|
+
ACKNOWLEDGED: 'ACKNOWLEDGED',
|
|
1755
|
+
UNCERTAIN: 'UNCERTAIN',
|
|
1756
|
+
};
|
|
1757
|
+
const MAX_POSTGRES_INT32 = 2_147_483_647;
|
|
1758
|
+
/**
|
|
1759
|
+
* Reservation advances the stored version once and must leave room for the
|
|
1760
|
+
* subsequent fenced settlement to advance it again.
|
|
1761
|
+
*/
|
|
1762
|
+
function isValidPublicationReservationVersion(value) {
|
|
1763
|
+
return (typeof value === 'number' &&
|
|
1764
|
+
Number.isSafeInteger(value) &&
|
|
1765
|
+
value >= 0 &&
|
|
1766
|
+
value < MAX_POSTGRES_INT32 - 1);
|
|
1767
|
+
}
|
|
1768
|
+
/** Settlement receives the reservation's current persisted version and advances it once. */
|
|
1769
|
+
function isValidPublicationSettlementVersion(value) {
|
|
1770
|
+
return (typeof value === 'number' &&
|
|
1771
|
+
Number.isSafeInteger(value) &&
|
|
1772
|
+
value > 0 &&
|
|
1773
|
+
value < MAX_POSTGRES_INT32);
|
|
1774
|
+
}
|
|
1775
|
+
function isValidPositiveSafeId(value) {
|
|
1776
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
1777
|
+
}
|
|
1778
|
+
function isExplanationPublicationChannel(value) {
|
|
1779
|
+
return (value === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS ||
|
|
1780
|
+
value === EXPLANATION_PUBLICATION_CHANNELS.ANSWER);
|
|
1781
|
+
}
|
|
1782
|
+
function isValidExplanationPublicationStaleRequest(value) {
|
|
1783
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
1784
|
+
return false;
|
|
1785
|
+
const request = value;
|
|
1786
|
+
return (hasOnlyFields(request, [
|
|
1787
|
+
'forgeInstance',
|
|
1788
|
+
'installationId',
|
|
1789
|
+
'actorId',
|
|
1790
|
+
'repositoryId',
|
|
1791
|
+
'pullRequestNumber',
|
|
1792
|
+
'requestedHeadSha',
|
|
1793
|
+
'sourceCommentId',
|
|
1794
|
+
'questionHash',
|
|
1795
|
+
'question',
|
|
1796
|
+
'channel',
|
|
1797
|
+
]) &&
|
|
1798
|
+
isValidExplanationInvocationRequest(request) &&
|
|
1799
|
+
isExplanationPublicationChannel(request.channel));
|
|
1800
|
+
}
|
|
1801
|
+
function hasCompleteExplanationPublicationIdentity(request, isValidVersion) {
|
|
1802
|
+
return (hasOnlyFields(request, [
|
|
1803
|
+
'forgeInstance',
|
|
1804
|
+
'installationId',
|
|
1805
|
+
'actorId',
|
|
1806
|
+
'repositoryId',
|
|
1807
|
+
'pullRequestNumber',
|
|
1808
|
+
'requestedHeadSha',
|
|
1809
|
+
'sourceCommentId',
|
|
1810
|
+
'questionHash',
|
|
1811
|
+
'question',
|
|
1812
|
+
'channel',
|
|
1813
|
+
'expectedPublicationVersion',
|
|
1814
|
+
'expectedBotAuthorId',
|
|
1815
|
+
]) &&
|
|
1816
|
+
isValidExplanationInvocationRequest(request) &&
|
|
1817
|
+
isExplanationPublicationChannel(request.channel) &&
|
|
1818
|
+
isValidVersion(request.expectedPublicationVersion) &&
|
|
1819
|
+
isValidPositiveSafeId(request.expectedBotAuthorId));
|
|
1820
|
+
}
|
|
1821
|
+
/** Validates the complete immutable identity and CREATE fence before database access. */
|
|
1822
|
+
export function validateExplanationPublicationCreateReservation(value) {
|
|
1823
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
1824
|
+
return null;
|
|
1825
|
+
return hasCompleteExplanationPublicationIdentity(value, isValidPublicationReservationVersion)
|
|
1826
|
+
? value
|
|
1827
|
+
: null;
|
|
1828
|
+
}
|
|
1829
|
+
/** Validates fenced CREATE settlement input without accepting caller-controlled lifecycle fields. */
|
|
1830
|
+
export function validateExplanationPublicationCreateSettlement(value) {
|
|
1831
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
1832
|
+
return null;
|
|
1833
|
+
const request = value;
|
|
1834
|
+
if (!hasOnlyFields(request, [
|
|
1835
|
+
'forgeInstance',
|
|
1836
|
+
'installationId',
|
|
1837
|
+
'actorId',
|
|
1838
|
+
'repositoryId',
|
|
1839
|
+
'pullRequestNumber',
|
|
1840
|
+
'requestedHeadSha',
|
|
1841
|
+
'sourceCommentId',
|
|
1842
|
+
'questionHash',
|
|
1843
|
+
'question',
|
|
1844
|
+
'channel',
|
|
1845
|
+
'expectedPublicationVersion',
|
|
1846
|
+
'expectedBotAuthorId',
|
|
1847
|
+
'publicationFence',
|
|
1848
|
+
'outcome',
|
|
1849
|
+
'commentId',
|
|
1850
|
+
]) ||
|
|
1851
|
+
!isNonEmptyText(request.publicationFence) ||
|
|
1852
|
+
(request.outcome !== EXPLANATION_PUBLICATION_CREATE_OUTCOMES.ACKNOWLEDGED &&
|
|
1853
|
+
request.outcome !== EXPLANATION_PUBLICATION_CREATE_OUTCOMES.UNCERTAIN)) {
|
|
1854
|
+
return null;
|
|
1855
|
+
}
|
|
1856
|
+
const reservation = {
|
|
1857
|
+
forgeInstance: request.forgeInstance,
|
|
1858
|
+
installationId: request.installationId,
|
|
1859
|
+
actorId: request.actorId,
|
|
1860
|
+
repositoryId: request.repositoryId,
|
|
1861
|
+
pullRequestNumber: request.pullRequestNumber,
|
|
1862
|
+
requestedHeadSha: request.requestedHeadSha,
|
|
1863
|
+
sourceCommentId: request.sourceCommentId,
|
|
1864
|
+
questionHash: request.questionHash,
|
|
1865
|
+
question: request.question,
|
|
1866
|
+
channel: request.channel,
|
|
1867
|
+
expectedPublicationVersion: request.expectedPublicationVersion,
|
|
1868
|
+
expectedBotAuthorId: request.expectedBotAuthorId,
|
|
1869
|
+
};
|
|
1870
|
+
if (!hasCompleteExplanationPublicationIdentity(reservation, isValidPublicationSettlementVersion))
|
|
1871
|
+
return null;
|
|
1872
|
+
if (request.outcome === EXPLANATION_PUBLICATION_CREATE_OUTCOMES.ACKNOWLEDGED) {
|
|
1873
|
+
return isValidPositiveSafeId(request.commentId)
|
|
1874
|
+
? value
|
|
1875
|
+
: null;
|
|
1876
|
+
}
|
|
1877
|
+
return request.commentId === undefined
|
|
1878
|
+
? value
|
|
1879
|
+
: null;
|
|
1880
|
+
}
|
|
1881
|
+
function explanationPublicationIdentityConditions(invocationKey, request) {
|
|
1882
|
+
return [
|
|
1883
|
+
eq(explanationInvocations.invocationKey, invocationKey),
|
|
1884
|
+
eq(explanationInvocations.forgeInstance, request.forgeInstance),
|
|
1885
|
+
eq(explanationInvocations.installationId, request.installationId),
|
|
1886
|
+
eq(explanationInvocations.actorId, request.actorId),
|
|
1887
|
+
eq(explanationInvocations.repositoryId, request.repositoryId),
|
|
1888
|
+
eq(explanationInvocations.pullRequestNumber, request.pullRequestNumber),
|
|
1889
|
+
eq(explanationInvocations.requestedHeadSha, request.requestedHeadSha),
|
|
1890
|
+
eq(explanationInvocations.sourceCommentId, request.sourceCommentId),
|
|
1891
|
+
eq(explanationInvocations.questionHash, request.questionHash),
|
|
1892
|
+
eq(explanationInvocations.question, request.question),
|
|
1893
|
+
];
|
|
1894
|
+
}
|
|
1895
|
+
async function observeExplanationPublication(db, invocationKey, request) {
|
|
1896
|
+
const [observed] = await db
|
|
1897
|
+
.select()
|
|
1898
|
+
.from(explanationInvocations)
|
|
1899
|
+
.where(and(...explanationPublicationIdentityConditions(invocationKey, request)))
|
|
1900
|
+
.limit(1);
|
|
1901
|
+
return observed;
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Records a superseded target without mutating its generation outcome, visible
|
|
1905
|
+
* comment identity, publication version, or the other publication channel.
|
|
1906
|
+
*/
|
|
1907
|
+
export async function markExplanationPublicationStale(db, value) {
|
|
1908
|
+
if (!isValidExplanationPublicationStaleRequest(value)) {
|
|
1909
|
+
return { status: 'invalid' };
|
|
1910
|
+
}
|
|
1911
|
+
const staleRequest = value;
|
|
1912
|
+
const invocationKey = deriveExplanationInvocationKey(staleRequest);
|
|
1913
|
+
if (invocationKey === null)
|
|
1914
|
+
return { status: 'invalid' };
|
|
1915
|
+
const conditions = explanationPublicationIdentityConditions(invocationKey, staleRequest);
|
|
1916
|
+
const [transitioned] = staleRequest.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
|
|
1917
|
+
? await db
|
|
1918
|
+
.update(explanationInvocations)
|
|
1919
|
+
.set({ progressPublicationStatus: 'STALE' })
|
|
1920
|
+
.where(and(...conditions, inArray(explanationInvocations.progressPublicationStatus, [
|
|
1921
|
+
'NOT_STARTED',
|
|
1922
|
+
'PUBLISHED',
|
|
1923
|
+
])))
|
|
1924
|
+
.returning()
|
|
1925
|
+
: await db
|
|
1926
|
+
.update(explanationInvocations)
|
|
1927
|
+
.set({ answerPublicationStatus: 'STALE' })
|
|
1928
|
+
.where(and(...conditions, inArray(explanationInvocations.answerPublicationStatus, ['NOT_STARTED', 'PUBLISHED'])))
|
|
1929
|
+
.returning();
|
|
1930
|
+
if (transitioned)
|
|
1931
|
+
return { status: 'stale', invocation: transitioned };
|
|
1932
|
+
const observed = await observeExplanationPublication(db, invocationKey, staleRequest);
|
|
1933
|
+
const status = staleRequest.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
|
|
1934
|
+
? observed?.progressPublicationStatus
|
|
1935
|
+
: observed?.answerPublicationStatus;
|
|
1936
|
+
return observed && status === 'STALE'
|
|
1937
|
+
? { status: 'stale', invocation: observed }
|
|
1938
|
+
: { status: 'unavailable' };
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* Persists the CREATE fence in one CAS before any caller can issue a comment POST.
|
|
1942
|
+
* The supplied version is pre-reservation; the returned row carries the current
|
|
1943
|
+
* reserved version that settlement must present exactly.
|
|
1944
|
+
*/
|
|
1945
|
+
export async function reserveExplanationPublicationCreate(db, value) {
|
|
1946
|
+
const request = validateExplanationPublicationCreateReservation(value);
|
|
1947
|
+
if (!request)
|
|
1948
|
+
return { status: 'invalid' };
|
|
1949
|
+
const invocationKey = deriveExplanationInvocationKey(request);
|
|
1950
|
+
if (invocationKey === null)
|
|
1951
|
+
return { status: 'invalid' };
|
|
1952
|
+
const conditions = explanationPublicationIdentityConditions(invocationKey, request);
|
|
1953
|
+
const [reserved] = request.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
|
|
1954
|
+
? await db
|
|
1955
|
+
.update(explanationInvocations)
|
|
1956
|
+
.set({
|
|
1957
|
+
progressPublicationStatus: 'CREATE_STARTED',
|
|
1958
|
+
progressPublicationCreateStartedAt: sql `clock_timestamp()`,
|
|
1959
|
+
progressExpectedBotAuthorId: request.expectedBotAuthorId,
|
|
1960
|
+
progressPublicationFence: sql `gen_random_uuid()::text`,
|
|
1961
|
+
progressPublicationVersion: request.expectedPublicationVersion + 1,
|
|
1962
|
+
})
|
|
1963
|
+
.where(and(...conditions, eq(explanationInvocations.progressPublicationStatus, 'NOT_STARTED'), eq(explanationInvocations.progressPublicationVersion, request.expectedPublicationVersion), isNull(explanationInvocations.progressPublicationCreateStartedAt), isNull(explanationInvocations.progressCommentId), isNull(explanationInvocations.progressExpectedBotAuthorId), isNull(explanationInvocations.progressPublicationFence)))
|
|
1964
|
+
.returning()
|
|
1965
|
+
: await db
|
|
1966
|
+
.update(explanationInvocations)
|
|
1967
|
+
.set({
|
|
1968
|
+
answerPublicationStatus: 'CREATE_STARTED',
|
|
1969
|
+
answerPublicationCreateStartedAt: sql `clock_timestamp()`,
|
|
1970
|
+
answerExpectedBotAuthorId: request.expectedBotAuthorId,
|
|
1971
|
+
answerPublicationFence: sql `gen_random_uuid()::text`,
|
|
1972
|
+
answerPublicationVersion: request.expectedPublicationVersion + 1,
|
|
1973
|
+
})
|
|
1974
|
+
.where(and(...conditions, eq(explanationInvocations.answerPublicationStatus, 'NOT_STARTED'), eq(explanationInvocations.answerPublicationVersion, request.expectedPublicationVersion), isNull(explanationInvocations.answerPublicationCreateStartedAt), isNull(explanationInvocations.answerCommentId), isNull(explanationInvocations.answerExpectedBotAuthorId), isNull(explanationInvocations.answerPublicationFence)))
|
|
1975
|
+
.returning();
|
|
1976
|
+
const fence = request.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
|
|
1977
|
+
? reserved?.progressPublicationFence
|
|
1978
|
+
: reserved?.answerPublicationFence;
|
|
1979
|
+
if (reserved && isNonEmptyText(fence)) {
|
|
1980
|
+
return { status: 'reserved', invocation: reserved, publicationFence: fence };
|
|
1981
|
+
}
|
|
1982
|
+
const observed = await observeExplanationPublication(db, invocationKey, request);
|
|
1983
|
+
return observed ? { status: 'observed', invocation: observed } : { status: 'unavailable' };
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Consumes a persisted CREATE fence so a duplicate or late POST result cannot gain authority.
|
|
1987
|
+
* The supplied version is the current reserved version; settlement increments it once.
|
|
1988
|
+
*/
|
|
1989
|
+
export async function settleExplanationPublicationCreate(db, value) {
|
|
1990
|
+
const request = validateExplanationPublicationCreateSettlement(value);
|
|
1991
|
+
if (!request)
|
|
1992
|
+
return { status: 'invalid' };
|
|
1993
|
+
const invocationKey = deriveExplanationInvocationKey(request);
|
|
1994
|
+
if (invocationKey === null)
|
|
1995
|
+
return { status: 'invalid' };
|
|
1996
|
+
const conditions = explanationPublicationIdentityConditions(invocationKey, request);
|
|
1997
|
+
const isAcknowledged = request.outcome === EXPLANATION_PUBLICATION_CREATE_OUTCOMES.ACKNOWLEDGED;
|
|
1998
|
+
const [settled] = request.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
|
|
1999
|
+
? await db
|
|
2000
|
+
.update(explanationInvocations)
|
|
2001
|
+
.set({
|
|
2002
|
+
progressPublicationStatus: isAcknowledged ? 'PUBLISHED' : 'AMBIGUOUS',
|
|
2003
|
+
progressCommentId: isAcknowledged ? request.commentId : null,
|
|
2004
|
+
progressPublicationFence: null,
|
|
2005
|
+
progressPublicationVersion: request.expectedPublicationVersion + 1,
|
|
2006
|
+
})
|
|
2007
|
+
.where(and(...conditions, eq(explanationInvocations.progressPublicationStatus, 'CREATE_STARTED'), eq(explanationInvocations.progressPublicationVersion, request.expectedPublicationVersion), eq(explanationInvocations.progressExpectedBotAuthorId, request.expectedBotAuthorId), eq(explanationInvocations.progressPublicationFence, request.publicationFence)))
|
|
2008
|
+
.returning()
|
|
2009
|
+
: await db
|
|
2010
|
+
.update(explanationInvocations)
|
|
2011
|
+
.set({
|
|
2012
|
+
answerPublicationStatus: isAcknowledged ? 'PUBLISHED' : 'AMBIGUOUS',
|
|
2013
|
+
answerCommentId: isAcknowledged ? request.commentId : null,
|
|
2014
|
+
answerPublicationFence: null,
|
|
2015
|
+
answerPublicationVersion: request.expectedPublicationVersion + 1,
|
|
2016
|
+
})
|
|
2017
|
+
.where(and(...conditions, eq(explanationInvocations.answerPublicationStatus, 'CREATE_STARTED'), eq(explanationInvocations.answerPublicationVersion, request.expectedPublicationVersion), eq(explanationInvocations.answerExpectedBotAuthorId, request.expectedBotAuthorId), eq(explanationInvocations.answerPublicationFence, request.publicationFence)))
|
|
2018
|
+
.returning();
|
|
2019
|
+
if (settled)
|
|
2020
|
+
return { status: 'settled', invocation: settled };
|
|
2021
|
+
const observed = await observeExplanationPublication(db, invocationKey, request);
|
|
2022
|
+
return observed ? { status: 'observed', invocation: observed } : { status: 'unavailable' };
|
|
2023
|
+
}
|
|
2024
|
+
/** Validates a PATCH reservation against an existing immutable publication comment. */
|
|
2025
|
+
export function validateExplanationPublicationPatchReservation(value) {
|
|
2026
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
2027
|
+
return null;
|
|
2028
|
+
const request = value;
|
|
2029
|
+
const reservation = {
|
|
2030
|
+
forgeInstance: request.forgeInstance,
|
|
2031
|
+
installationId: request.installationId,
|
|
2032
|
+
actorId: request.actorId,
|
|
2033
|
+
repositoryId: request.repositoryId,
|
|
2034
|
+
pullRequestNumber: request.pullRequestNumber,
|
|
2035
|
+
requestedHeadSha: request.requestedHeadSha,
|
|
2036
|
+
sourceCommentId: request.sourceCommentId,
|
|
2037
|
+
questionHash: request.questionHash,
|
|
2038
|
+
question: request.question,
|
|
2039
|
+
channel: request.channel,
|
|
2040
|
+
expectedPublicationVersion: request.expectedPublicationVersion,
|
|
2041
|
+
expectedBotAuthorId: request.expectedBotAuthorId,
|
|
2042
|
+
};
|
|
2043
|
+
return hasOnlyFields(request, [
|
|
2044
|
+
'forgeInstance',
|
|
2045
|
+
'installationId',
|
|
2046
|
+
'actorId',
|
|
2047
|
+
'repositoryId',
|
|
2048
|
+
'pullRequestNumber',
|
|
2049
|
+
'requestedHeadSha',
|
|
2050
|
+
'sourceCommentId',
|
|
2051
|
+
'questionHash',
|
|
2052
|
+
'question',
|
|
2053
|
+
'channel',
|
|
2054
|
+
'expectedPublicationVersion',
|
|
2055
|
+
'expectedBotAuthorId',
|
|
2056
|
+
'commentId',
|
|
2057
|
+
]) &&
|
|
2058
|
+
hasCompleteExplanationPublicationIdentity(reservation, isValidPublicationReservationVersion) &&
|
|
2059
|
+
isValidPositiveSafeId(request.commentId)
|
|
2060
|
+
? value
|
|
2061
|
+
: null;
|
|
2062
|
+
}
|
|
2063
|
+
/** Validates fenced PATCH settlement input without accepting caller lifecycle fields. */
|
|
2064
|
+
export function validateExplanationPublicationPatchSettlement(value) {
|
|
2065
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
2066
|
+
return null;
|
|
2067
|
+
const request = value;
|
|
2068
|
+
if (!hasOnlyFields(request, [
|
|
2069
|
+
'forgeInstance',
|
|
2070
|
+
'installationId',
|
|
2071
|
+
'actorId',
|
|
2072
|
+
'repositoryId',
|
|
2073
|
+
'pullRequestNumber',
|
|
2074
|
+
'requestedHeadSha',
|
|
2075
|
+
'sourceCommentId',
|
|
2076
|
+
'questionHash',
|
|
2077
|
+
'question',
|
|
2078
|
+
'channel',
|
|
2079
|
+
'expectedPublicationVersion',
|
|
2080
|
+
'expectedBotAuthorId',
|
|
2081
|
+
'commentId',
|
|
2082
|
+
'publicationFence',
|
|
2083
|
+
'outcome',
|
|
2084
|
+
]) ||
|
|
2085
|
+
!isNonEmptyText(request.publicationFence) ||
|
|
2086
|
+
(request.outcome !== EXPLANATION_PUBLICATION_CREATE_OUTCOMES.ACKNOWLEDGED &&
|
|
2087
|
+
request.outcome !== EXPLANATION_PUBLICATION_CREATE_OUTCOMES.UNCERTAIN)) {
|
|
2088
|
+
return null;
|
|
2089
|
+
}
|
|
2090
|
+
const reservation = {
|
|
2091
|
+
forgeInstance: request.forgeInstance,
|
|
2092
|
+
installationId: request.installationId,
|
|
2093
|
+
actorId: request.actorId,
|
|
2094
|
+
repositoryId: request.repositoryId,
|
|
2095
|
+
pullRequestNumber: request.pullRequestNumber,
|
|
2096
|
+
requestedHeadSha: request.requestedHeadSha,
|
|
2097
|
+
sourceCommentId: request.sourceCommentId,
|
|
2098
|
+
questionHash: request.questionHash,
|
|
2099
|
+
question: request.question,
|
|
2100
|
+
channel: request.channel,
|
|
2101
|
+
expectedPublicationVersion: request.expectedPublicationVersion,
|
|
2102
|
+
expectedBotAuthorId: request.expectedBotAuthorId,
|
|
2103
|
+
};
|
|
2104
|
+
return hasCompleteExplanationPublicationIdentity(reservation, isValidPublicationSettlementVersion) && isValidPositiveSafeId(request.commentId)
|
|
2105
|
+
? value
|
|
2106
|
+
: null;
|
|
2107
|
+
}
|
|
2108
|
+
/** Persists PATCH_STARTED before caller I/O without changing the CREATE timestamp or comment. */
|
|
2109
|
+
export async function reserveExplanationPublicationPatch(db, value) {
|
|
2110
|
+
const request = validateExplanationPublicationPatchReservation(value);
|
|
2111
|
+
if (!request)
|
|
2112
|
+
return { status: 'invalid' };
|
|
2113
|
+
const invocationKey = deriveExplanationInvocationKey(request);
|
|
2114
|
+
if (invocationKey === null)
|
|
2115
|
+
return { status: 'invalid' };
|
|
2116
|
+
const conditions = explanationPublicationIdentityConditions(invocationKey, request);
|
|
2117
|
+
const [reserved] = request.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
|
|
2118
|
+
? await db
|
|
2119
|
+
.update(explanationInvocations)
|
|
2120
|
+
.set({
|
|
2121
|
+
progressPublicationStatus: 'PATCH_STARTED',
|
|
2122
|
+
progressPublicationFence: sql `gen_random_uuid()::text`,
|
|
2123
|
+
progressPublicationVersion: request.expectedPublicationVersion + 1,
|
|
2124
|
+
})
|
|
2125
|
+
.where(and(...conditions, eq(explanationInvocations.progressPublicationStatus, 'PUBLISHED'), eq(explanationInvocations.progressPublicationVersion, request.expectedPublicationVersion), eq(explanationInvocations.progressExpectedBotAuthorId, request.expectedBotAuthorId), eq(explanationInvocations.progressCommentId, request.commentId), isNull(explanationInvocations.progressPublicationFence)))
|
|
2126
|
+
.returning()
|
|
2127
|
+
: await db
|
|
2128
|
+
.update(explanationInvocations)
|
|
2129
|
+
.set({
|
|
2130
|
+
answerPublicationStatus: 'PATCH_STARTED',
|
|
2131
|
+
answerPublicationFence: sql `gen_random_uuid()::text`,
|
|
2132
|
+
answerPublicationVersion: request.expectedPublicationVersion + 1,
|
|
2133
|
+
})
|
|
2134
|
+
.where(and(...conditions, eq(explanationInvocations.answerPublicationStatus, 'PUBLISHED'), eq(explanationInvocations.answerPublicationVersion, request.expectedPublicationVersion), eq(explanationInvocations.answerExpectedBotAuthorId, request.expectedBotAuthorId), eq(explanationInvocations.answerCommentId, request.commentId), isNull(explanationInvocations.answerPublicationFence)))
|
|
2135
|
+
.returning();
|
|
2136
|
+
const fence = request.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
|
|
2137
|
+
? reserved?.progressPublicationFence
|
|
2138
|
+
: reserved?.answerPublicationFence;
|
|
2139
|
+
if (reserved && isNonEmptyText(fence)) {
|
|
2140
|
+
return { status: 'reserved', invocation: reserved, publicationFence: fence };
|
|
2141
|
+
}
|
|
2142
|
+
const observed = await observeExplanationPublication(db, invocationKey, request);
|
|
2143
|
+
return observed ? { status: 'observed', invocation: observed } : { status: 'unavailable' };
|
|
2144
|
+
}
|
|
2145
|
+
/** Consumes a matching PATCH fence and preserves the existing comment identity. */
|
|
2146
|
+
export async function settleExplanationPublicationPatch(db, value) {
|
|
2147
|
+
const request = validateExplanationPublicationPatchSettlement(value);
|
|
2148
|
+
if (!request)
|
|
2149
|
+
return { status: 'invalid' };
|
|
2150
|
+
const invocationKey = deriveExplanationInvocationKey(request);
|
|
2151
|
+
if (invocationKey === null)
|
|
2152
|
+
return { status: 'invalid' };
|
|
2153
|
+
const conditions = explanationPublicationIdentityConditions(invocationKey, request);
|
|
2154
|
+
const isAcknowledged = request.outcome === EXPLANATION_PUBLICATION_CREATE_OUTCOMES.ACKNOWLEDGED;
|
|
2155
|
+
const [settled] = request.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
|
|
2156
|
+
? await db
|
|
2157
|
+
.update(explanationInvocations)
|
|
2158
|
+
.set({
|
|
2159
|
+
progressPublicationStatus: isAcknowledged ? 'PUBLISHED' : 'AMBIGUOUS',
|
|
2160
|
+
progressPublicationFence: null,
|
|
2161
|
+
progressPublicationVersion: request.expectedPublicationVersion + 1,
|
|
2162
|
+
})
|
|
2163
|
+
.where(and(...conditions, eq(explanationInvocations.progressPublicationStatus, 'PATCH_STARTED'), eq(explanationInvocations.progressPublicationVersion, request.expectedPublicationVersion), eq(explanationInvocations.progressExpectedBotAuthorId, request.expectedBotAuthorId), eq(explanationInvocations.progressCommentId, request.commentId), eq(explanationInvocations.progressPublicationFence, request.publicationFence)))
|
|
2164
|
+
.returning()
|
|
2165
|
+
: await db
|
|
2166
|
+
.update(explanationInvocations)
|
|
2167
|
+
.set({
|
|
2168
|
+
answerPublicationStatus: isAcknowledged ? 'PUBLISHED' : 'AMBIGUOUS',
|
|
2169
|
+
answerPublicationFence: null,
|
|
2170
|
+
answerPublicationVersion: request.expectedPublicationVersion + 1,
|
|
2171
|
+
})
|
|
2172
|
+
.where(and(...conditions, eq(explanationInvocations.answerPublicationStatus, 'PATCH_STARTED'), eq(explanationInvocations.answerPublicationVersion, request.expectedPublicationVersion), eq(explanationInvocations.answerExpectedBotAuthorId, request.expectedBotAuthorId), eq(explanationInvocations.answerCommentId, request.commentId), eq(explanationInvocations.answerPublicationFence, request.publicationFence)))
|
|
2173
|
+
.returning();
|
|
2174
|
+
if (settled)
|
|
2175
|
+
return { status: 'settled', invocation: settled };
|
|
2176
|
+
const observed = await observeExplanationPublication(db, invocationKey, request);
|
|
2177
|
+
return observed ? { status: 'observed', invocation: observed } : { status: 'unavailable' };
|
|
2178
|
+
}
|
|
1084
2179
|
//# sourceMappingURL=queries.js.map
|