T-HUB/packages/publishing-execution/src/publishing.repository.ts
glass beadian a6843e904b feat: rich-media publishing chain (repository gate lift + assembly builder + produce store)
publishing-execution: approved posts may carry mediaAssetIds + altTexts.
MAX_MEDIA_ATTACHMENTS = 4 (Mastodon's cap). The old text-only gate is lifted.
assertApprovedMediaShape validates the shapes; the provider-side
mediaIds stay reserved.

media-worker: producer module — pg-store (claim via produce_job_claim
SECURITY DEFINER fn), runner (getObject → filter_complex → putContent →
derived asset), command-builder (normalize, transpose, drawtext, ducking),
s3-storage (SPENC1 decrypt + putContent), env (lane, font). Migration 040:
produce_job_claim + produce_job_workspace definer functions.

produce.ts: scratch dir bootstrap at startup.
2026-10-15 10:07:09 +00:00

1274 lines
56 KiB
TypeScript

import { publishingIdempotencyKey } from "@tactical-media-broadcast-hub/core";
import { canonicalPostHash } from "./approval.js";
import type { Pool, PoolClient } from "pg";
/**
* WP3 execution spine: the tenant-scoped claim / dispatch-gate / receipt /
* finalization repository behind the scheduler's publish activity (plan §4).
*
* PostgreSQL owns the fences. Every method enters the tenant through the same
* transaction-local `set_config('app.workspace_id', …, true)` as withTenant,
* so RLS binds as sp_app. Mutations lock in one order — workspace row, draft,
* schedule, attempt, then the audit chain — matching team/group/credential
* mutations, which take the workspace row FOR UPDATE before domain rows.
*
* Bigint columns cross this boundary as decimal strings (pg's int8 default).
*/
export const PUBLISH_ERROR_CODES = [
"AUTHZ_REVOKED",
"CREDENTIAL_CHANGED",
"SUPERSEDED",
"CANCELLED",
"PUBLISH_OUTCOME_UNKNOWN",
"RETRY_EXHAUSTED",
"EXECUTION_FENCE_MISSING",
"LEGACY_AUTHORIZATION_MISSING",
] as const;
export type PublishErrorCode = (typeof PUBLISH_ERROR_CODES)[number] | (string & {});
/**
* Provider-compatible ceiling on attachments per post (the API's
* media.service MAX_MEDIA_PER_POST; Mastodon's connector MAX_MEDIA). The API
* validated it at intake; the repository re-checks the approved snapshot so a
* forged snapshot can never reach a connector.
*/
export const MAX_MEDIA_ATTACHMENTS = 4;
/** Roles that may use every connection without group sharing (groups.service WORKSPACE_WIDE_ROLES). */
const WORKSPACE_WIDE_ROLES = ["Owner", "Admin"];
const DEFAULT_LEASE_SECONDS = 90;
export const MAX_DISPATCHES = 5;
const SCHEDULER_ROLE = "sp_scheduler";
/** Identity of one business attempt as carried by the workflow input. */
export interface AttemptRef {
workspaceId: string;
scheduleId: string;
generation: string;
idempotencyKey: string;
}
/** Proof of current ownership; every journal write CASes owner_token + claim_epoch. */
export interface Lease {
workspaceId: string;
attemptId: string;
scheduleId: string;
draftId: string;
idempotencyKey: string;
generation: string;
ownerToken: string;
claimEpoch: string;
leaseUntil: Date;
}
export interface ExecutionPost {
text: string;
/** media_assets ids (0..4). Existence was proven at intake; dispatch resolves them. */
mediaAssetIds: string[];
/** Optional alt texts, positionally aligned with mediaAssetIds (null = none). */
altTexts?: (string | null)[];
visibility: string;
providerMetadata: Record<string, unknown>;
}
export interface ClaimedJob {
lease: Lease;
attemptNo: string;
retryCount: number;
firstDispatchStartedAt?: Date;
providerRetryDeadline?: Date;
post: ExecutionPost;
connection: { id: string; provider: string; channelId: string; fields: Record<string, unknown>; createdAt: Date };
credential: { id: string; generation: string };
/** A previous owner stamped a dispatch whose outcome is not settled. */
priorDispatch: { startedAt: Date; count: number; providerRetryDeadline?: Date } | undefined;
}
export interface StoredPublication {
id: string;
workspaceId: string;
draftId: string;
connectionId: string;
idempotencyKey: string;
remoteId: string;
remoteUrl?: string;
publishedAt: Date;
providerMetadata: Record<string, unknown>;
}
export interface StoredReceipt {
id: string;
workspaceId: string;
attemptId: string;
remoteId: string;
remoteUrl?: string;
publishedAt?: Date;
providerMetadata: Record<string, unknown>;
receivedAt: Date;
}
export interface RemoteResult {
remoteId: string;
remoteUrl?: string;
publishedAt?: Date;
/** Caller-sanitized, allowlisted provider metadata (never tokens or raw bodies). */
providerMetadata?: Record<string, unknown>;
}
export type ClaimOutcome =
| { kind: "claimed"; job: ClaimedJob }
| { kind: "finalize"; job: ClaimedJob; receipt: StoredReceipt }
| { kind: "done"; publication: StoredPublication }
| { kind: "wait"; until: Date; reason: "not_due" | "busy" }
| { kind: "paused"; code: PublishErrorCode }
| { kind: "superseded"; code: PublishErrorCode }
| { kind: "failed"; code: PublishErrorCode }
| { kind: "unknown"; code: PublishErrorCode };
export type GateOutcome<T> =
| { kind: "sent"; value: T }
| { kind: "finalize" }
| { kind: "lost" }
| { kind: "paused"; code: PublishErrorCode }
| { kind: "superseded"; code: PublishErrorCode }
| { kind: "failed"; code: PublishErrorCode }
| { kind: "unknown"; code: PublishErrorCode };
export type FinalizeOutcome = { kind: "done"; publication: StoredPublication } | { kind: "stale" };
export type SettleOutcome = { kind: "settled"; until?: Date } | { kind: "stale" };
export interface ScheduleAttemptInput {
workspaceId: string;
draftId: string;
connectionId: string;
scheduledByUserId: string;
requiredPermission: "publication.schedule" | "publication.publish";
publishAt: Date;
expectedPrevious?: { state: "failed" | "needs_reauthorization"; generation: string };
timezone?: string;
localPublishAt?: string;
tzdataVersion?: string;
}
export interface ScheduledAttempt {
scheduleId: string;
attemptId: string;
attemptNo: string;
generation: string;
idempotencyKey: string;
}
export interface DueWork {
evidenceOnly: boolean;
workspaceId: string;
scheduleId: string;
idempotencyKey: string;
generation: string;
publishAt: Date;
}
/** Conflicting or missing evidence: operator-visible, never auto-repaired. */
export class PublishingIntegrityError extends Error {
constructor(readonly code: string, message: string) {
super(message);
this.name = "PublishingIntegrityError";
}
}
/** A scheduling command that the current draft/schedule/credential state refuses. */
export class PublishingStateError extends Error {
constructor(readonly code: string, message: string) {
super(message);
this.name = "PublishingStateError";
}
}
/** `wp3:<workspaceUUID>:<draftUUID>:a<attemptNo>:publish` — reused verbatim by every replay. */
export function publishIdempotencyKey(workspaceId: string, draftId: string, attemptNo: string): string {
return publishingIdempotencyKey({ workspaceId, draftId, attemptNo });
}
type Row = Record<string, unknown>;
interface Locked {
epoch: string;
connection: Row;
draft: Row;
schedule: Row;
attempt: Row;
}
export interface PublishingRepositoryOptions {
leaseSeconds?: number;
}
export class PublishingExecutionRepository {
private readonly leaseSeconds: number;
constructor(
private readonly pool: Pool,
options: PublishingRepositoryOptions = {},
) {
this.leaseSeconds = options.leaseSeconds ?? DEFAULT_LEASE_SECONDS;
}
// -------------------------------------------------------------------------
// Discovery (SECURITY DEFINER, identifiers only)
// -------------------------------------------------------------------------
async listDue(limit = 100): Promise<DueWork[]> {
const res = await this.pool.query("SELECT * FROM publishing_due_schedules($1)", [limit]);
return (res.rows as Row[]).map((r) => ({
evidenceOnly: r.evidence_only === true,
workspaceId: String(r.workspace_id),
scheduleId: String(r.schedule_id),
idempotencyKey: String(r.idempotency_key),
generation: String(r.generation),
publishAt: new Date(String(r.publish_at)),
}));
}
async resolveWorkspace(idempotencyKey: string): Promise<string | undefined> {
const res = await this.pool.query("SELECT publishing_schedule_workspace($1) AS workspace_id", [idempotencyKey]);
const id = (res.rows[0] as Row | undefined)?.workspace_id;
return id === null || id === undefined ? undefined : String(id);
}
async findPublication(workspaceId: string, idempotencyKey: string): Promise<StoredPublication | undefined> {
return this.inTenant(workspaceId, async (client) => {
const res = await client.query("SELECT * FROM publications WHERE idempotency_key = $1", [idempotencyKey]);
const row = res.rows[0] as Row | undefined;
return row === undefined ? undefined : publicationFromRow(row);
});
}
// -------------------------------------------------------------------------
// Scheduling: a new business attempt (called inside the service's tenant tx)
// -------------------------------------------------------------------------
/**
* Creates or re-arms the draft's single schedule row for a NEW business
* attempt: allocates attempt_no from the locked draft counter, snapshots
* principal / approval proof / exact credential generation, bumps the
* schedule generation (voiding every older workflow) and journals a ready
* attempt. Must run in a tenant transaction for `input.workspaceId`.
*/
async scheduleAttempt(client: PoolClient, input: ScheduleAttemptInput): Promise<ScheduledAttempt> {
const epoch = await lockWorkspace(client, input.workspaceId);
const draftRes = await client.query("SELECT * FROM drafts WHERE id = $1 FOR UPDATE", [input.draftId]);
const draft = draftRes.rows[0] as Row | undefined;
if (draft === undefined) throw new PublishingStateError("DRAFT_NOT_FOUND", "draft not found");
if (
draft.state !== "approved" ||
draft.approved_revision === null ||
String(draft.approved_revision) !== String(draft.revision) ||
draft.approved_hash === null ||
draft.reviewer_user_id === null
) {
throw new PublishingStateError("DRAFT_NOT_APPROVED", "draft has no current approval");
}
const approvedPost = draft.post as ExecutionPost;
assertApprovedMediaShape(approvedPost);
if (approvedPost.providerMetadata.connectionId !== input.connectionId || canonicalPostHash(approvedPost) !== draft.approved_hash) {
throw new PublishingStateError("APPROVAL_MISMATCH", "approved content or target does not match");
}
const existingRes = await client.query("SELECT * FROM scheduled_publications WHERE draft_id = $1 FOR UPDATE", [
input.draftId,
]);
const existing = existingRes.rows[0] as Row | undefined;
if (input.expectedPrevious && (!existing || existing.state !== input.expectedPrevious.state || String(existing.generation) !== input.expectedPrevious.generation)) throw new PublishingStateError("SCHEDULE_CHANGED", "schedule state or generation changed");
if (existing !== undefined && ["pending", "in_flight", "done"].includes(String(existing.state))) {
if (existing.state === "pending" && String(existing.connection_id) === input.connectionId && toDate(existing.publish_at).getTime() === input.publishAt.getTime() && String(existing.scheduled_by_user_id) === input.scheduledByUserId && existing.required_permission === input.requiredPermission && String(existing.post_hash) === String(draft.approved_hash)) {
const prior = await client.query("SELECT id FROM publishing_attempts WHERE idempotency_key = $1", [existing.idempotency_key]);
if (prior.rows[0]) return { scheduleId: String(existing.id), attemptId: String(prior.rows[0].id), attemptNo: String(existing.attempt_no), generation: String(existing.generation), idempotencyKey: String(existing.idempotency_key) };
}
throw new PublishingStateError("SCHEDULE_ACTIVE", `schedule is ${String(existing.state)}`);
}
if (existing !== undefined) {
const unsettled = await client.query(
`SELECT 1 FROM publishing_attempts
WHERE schedule_id = $1
AND (status IN ('in_progress', 'unknown') OR (status = 'failed' AND error_code = 'PUBLISH_OUTCOME_UNKNOWN') OR EXISTS (SELECT 1 FROM publishing_receipts r WHERE r.attempt_id = publishing_attempts.id AND r.workspace_id = publishing_attempts.workspace_id))`,
[existing.id],
);
if ((unsettled.rowCount ?? 0) > 0) {
throw new PublishingStateError("PUBLISH_OUTCOME_UNKNOWN", "a previous attempt has an unsettled outcome");
}
}
const credRes = await client.query(
`SELECT cr.id, cr.generation FROM connections c
JOIN credentials cr ON cr.connection_id = c.id AND cr.kind = 'connector-oauth' AND cr.revoked_at IS NULL
WHERE c.id = $1 AND c.disconnected_at IS NULL`,
[input.connectionId],
);
const credential = credRes.rows[0] as Row | undefined;
if (credential === undefined) throw new PublishingStateError("CREDENTIAL_CHANGED", "connection has no active credential");
const authz = await authorize(client, {
workspaceId: input.workspaceId,
principal: input.scheduledByUserId,
permission: input.requiredPermission,
reviewer: String(draft.reviewer_user_id),
connectionId: input.connectionId,
credentialId: String(credential.id),
credentialGeneration: String(credential.generation),
});
if (authz !== undefined) throw new PublishingStateError(authz, "scheduling principal is not authorized");
const counter = await client.query(
"UPDATE drafts SET publication_attempt = publication_attempt + 1 WHERE id = $1 RETURNING publication_attempt",
[input.draftId],
);
const attemptNo = String((counter.rows[0] as Row).publication_attempt);
const key = publishIdempotencyKey(input.workspaceId, input.draftId, attemptNo);
const fence = [
input.connectionId,
input.publishAt,
key,
input.scheduledByUserId,
input.requiredPermission,
epoch,
String(draft.approved_revision),
String(draft.approved_hash),
String(credential.id),
String(credential.generation),
attemptNo,
input.timezone ?? "UTC",
input.localPublishAt ?? null,
input.tzdataVersion ?? null,
];
let schedule: Row;
if (existing === undefined) {
const ins = await client.query(
`INSERT INTO scheduled_publications
(workspace_id, draft_id, connection_id, publish_at, idempotency_key, scheduled_by_user_id,
required_permission, authorized_epoch, approved_revision, post_hash, credential_id,
credential_generation, attempt_no, timezone, local_publish_at, tzdata_version,
generation, execution_version, state, next_attempt_at)
VALUES ($15, $16, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 1, 1, 'pending', $2)
RETURNING id, generation`,
[...fence, input.workspaceId, input.draftId],
);
schedule = ins.rows[0] as Row;
} else {
const upd = await client.query(
`UPDATE scheduled_publications SET
connection_id = $1, publish_at = $2, idempotency_key = $3, scheduled_by_user_id = $4,
required_permission = $5, authorized_epoch = $6, approved_revision = $7, post_hash = $8,
credential_id = $9, credential_generation = $10, attempt_no = $11, timezone = $12,
local_publish_at = $13, tzdata_version = $14, generation = generation + 1,
execution_version = 1, state = 'pending', next_attempt_at = $2, retry_count = 0,
error = NULL, error_code = NULL
WHERE id = $15
RETURNING id, generation`,
[...fence, existing.id],
);
schedule = upd.rows[0] as Row;
}
const attempt = await client.query(
`INSERT INTO publishing_attempts
(workspace_id, schedule_id, draft_id, connection_id, attempt_no, idempotency_key,
schedule_generation, post_snapshot, post_hash, scheduled_by_user_id, required_permission,
approved_revision, reviewer_user_id, credential_id, credential_generation, authorized_epoch)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id`,
[
input.workspaceId,
schedule.id,
input.draftId,
input.connectionId,
attemptNo,
key,
schedule.generation,
draft.post,
draft.approved_hash,
input.scheduledByUserId,
input.requiredPermission,
draft.approved_revision,
draft.reviewer_user_id,
credential.id,
credential.generation,
epoch,
],
);
return {
scheduleId: String(schedule.id),
attemptId: String((attempt.rows[0] as Row).id),
attemptNo,
generation: String(schedule.generation),
idempotencyKey: key,
};
}
/** Cancellation retains journal and evidence; only undispatched intent can be cancelled. */
async cancelAttempt(client: PoolClient, workspaceId: string, draftId: string): Promise<void> {
await lockWorkspace(client, workspaceId);
await client.query("SELECT id FROM drafts WHERE id = $1 FOR UPDATE", [draftId]);
const schedules = await client.query("SELECT * FROM scheduled_publications WHERE draft_id = $1 FOR UPDATE", [draftId]);
const schedule = schedules.rows[0] as Row | undefined;
if (!schedule) throw new PublishingStateError("SCHEDULE_NOT_FOUND", "schedule not found");
const attempts = await client.query("SELECT * FROM publishing_attempts WHERE schedule_id = $1 ORDER BY attempt_no FOR UPDATE", [schedule.id]);
if ((attempts.rows as Row[]).some((a) => a.status === "unknown" || (a.dispatch_started_at !== null && a.status !== "completed" && a.status !== "failed"))) throw new PublishingStateError("PUBLISH_OUTCOME_UNKNOWN", "dispatch is unsettled");
if (!["pending", "in_flight", "needs_reauthorization", "cancelled"].includes(String(schedule.state))) throw new PublishingStateError("SCHEDULE_ACTIVE", "schedule cannot be cancelled");
await client.query("UPDATE publishing_attempts SET status = 'abandoned', owner_token = NULL, lease_until = NULL, error_code = 'CANCELLED' WHERE schedule_id = $1 AND status IN ('ready', 'in_progress')", [schedule.id]);
await client.query("UPDATE scheduled_publications SET state = 'cancelled', generation = generation + 1, error_code = 'CANCELLED' WHERE id = $1 AND state <> 'cancelled'", [schedule.id]);
}
// -------------------------------------------------------------------------
// Claim
// -------------------------------------------------------------------------
/**
* Atomic claim under workspace FOR UPDATE + draft/schedule/attempt row locks.
* A live owner yields `wait`; an expired lease is recoverable by exactly one
* claimer (owner_token change advances claim_epoch in the DB). Existing
* evidence short-circuits to `done` / `finalize` — never a second send.
*/
async claim(ref: AttemptRef): Promise<ClaimOutcome> {
return this.inTenant(ref.workspaceId, async (client) => {
const epoch = await lockWorkspace(client, ref.workspaceId);
const pub = await client.query("SELECT * FROM publications WHERE idempotency_key = $1", [ref.idempotencyKey]);
if (pub.rows[0] !== undefined) return { kind: "done", publication: publicationFromRow(pub.rows[0] as Row) };
const locked = await lockAttemptByKey(client, ref.idempotencyKey, epoch);
if (locked === undefined) return { kind: "failed", code: "EXECUTION_FENCE_MISSING" };
const { draft, schedule, attempt } = locked;
if (String(schedule.id) !== ref.scheduleId) return { kind: "failed", code: "EXECUTION_FENCE_MISSING" };
const now = await dbNow(client);
const status = String(attempt.status);
if (status === "completed") {
throw new PublishingIntegrityError("PUBLICATION_MISSING", `attempt ${String(attempt.id)} completed without a publication`);
}
// Durable evidence wins over every other fence: finalize, never resend.
const receipt = await readReceipt(client, String(attempt.id));
if (receipt !== undefined) {
if (status === "abandoned") {
throw new PublishingIntegrityError("RECEIPT_ON_ABANDONED_ATTEMPT", `attempt ${String(attempt.id)} is abandoned but has a receipt`);
}
if (status === "in_progress" && toDate(attempt.lease_until) > now) {
return { kind: "wait", until: toDate(attempt.lease_until), reason: "busy" };
}
const lease = await this.takeOwnership(client, locked, epoch);
return { kind: "finalize", job: jobFromLocked(locked, lease), receipt };
}
// A ref whose generation disagrees with its own key's attempt is not
// this attempt's workflow: refuse it without touching the real attempt.
if (String(attempt.schedule_generation) !== ref.generation) return { kind: "superseded", code: "SUPERSEDED" };
if (
String(schedule.generation) !== String(attempt.schedule_generation) ||
String(schedule.idempotency_key) !== String(attempt.idempotency_key)
) {
return supersede(client, locked, "SUPERSEDED");
}
if (Number(schedule.execution_version) !== 1) return { kind: "failed", code: "EXECUTION_FENCE_MISSING" };
switch (status) {
case "abandoned":
return { kind: "superseded", code: String(attempt.error_code ?? "SUPERSEDED") };
case "failed":
return { kind: "failed", code: String(attempt.error_code ?? schedule.error_code ?? "FAILED") };
case "unknown":
return { kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN" };
case "ready": {
const scheduleState = String(schedule.state);
if (scheduleState === "cancelled") return supersede(client, locked, "CANCELLED");
if (scheduleState === "needs_reauthorization") return { kind: "paused", code: String(schedule.error_code) };
if (scheduleState !== "pending") return { kind: "failed", code: String(schedule.error_code ?? "FAILED") };
const dueAt = maxDate(toDate(schedule.next_attempt_at), toDate(schedule.publish_at));
if (dueAt > now) return { kind: "wait", until: dueAt, reason: "not_due" };
if (draft.state !== "approved") return supersede(client, locked, "SUPERSEDED");
break;
}
case "in_progress": {
if (toDate(attempt.lease_until) > now) {
return { kind: "wait", until: toDate(attempt.lease_until), reason: "busy" };
}
// Recovery: only this exact in-flight schedule's scheduled draft.
if (schedule.state !== "in_flight" || draft.state !== "scheduled") {
throw new PublishingIntegrityError("RECOVERY_STATE", `attempt ${String(attempt.id)} in progress outside an in-flight schedule`);
}
break;
}
default:
throw new PublishingIntegrityError("ATTEMPT_STATUS", `unexpected attempt status ${status}`);
}
if (!contentFenceHolds(draft, attempt)) return supersede(client, locked, "SUPERSEDED");
const denied = await authorize(client, authzInput(attempt));
if (denied !== undefined) {
if (attempt.dispatch_started_at !== null) {
await settleUnknown(client, locked, "PUBLISH_OUTCOME_UNKNOWN");
return { kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN" };
}
await pause(client, locked, denied);
return { kind: "paused", code: denied };
}
const lease = await this.takeOwnership(client, locked, epoch);
return { kind: "claimed", job: jobFromLocked(locked, lease) };
});
}
/** Renews a live lease; false once the lease expired or ownership moved on. */
async heartbeat(lease: Lease): Promise<boolean> {
return this.inTenant(lease.workspaceId, async (client) => {
const res = await client.query(
`UPDATE publishing_attempts SET lease_until = now() + make_interval(secs => $4::double precision)
WHERE id = $1 AND owner_token = $2 AND claim_epoch = $3
AND status = 'in_progress' AND lease_until > now()
RETURNING lease_until`,
[lease.attemptId, lease.ownerToken, lease.claimEpoch, this.leaseSeconds],
);
const row = res.rows[0] as Row | undefined;
if (row === undefined) return false;
lease.leaseUntil = toDate(row.lease_until);
return true;
});
}
// -------------------------------------------------------------------------
// Dispatch gate
// -------------------------------------------------------------------------
/**
* Holds the workspace row FOR SHARE in a dedicated READ COMMITTED tenant
* transaction across `send`, so any approval/authz/credential mutation
* (which takes workspace FOR UPDATE first) either committed before the
* fence re-read — and blocks the send — or serializes after it. The dispatch
* stamp commits in a separate short transaction (owner/claim_epoch CAS,
* dispatch_count < MAX) so receipts and heartbeats never wait on the gate.
*
* `allowRedispatch` with `dedupeWindowMs` is the connector's declared
* token-dedupe policy (e.g. verified Mastodon origins). Without it an
* unsettled earlier dispatch becomes PUBLISH_OUTCOME_UNKNOWN, never a resend.
*/
async withDispatchGate<T>(
lease: Lease,
policy: { allowRedispatch?: boolean; dedupeWindowMs?: number },
send: () => Promise<T>,
): Promise<GateOutcome<T>> {
const client = await this.pool.connect();
let open = false;
const end = async (sql: "COMMIT" | "ROLLBACK"): Promise<void> => {
if (!open) return;
open = false;
await client.query(sql).catch(() => undefined);
};
try {
await client.query("BEGIN ISOLATION LEVEL READ COMMITTED");
open = true;
await client.query("SELECT set_config('app.workspace_id', $1, true)", [lease.workspaceId]);
await client.query("SELECT 1 FROM workspaces WHERE id = $1 FOR SHARE", [lease.workspaceId]);
const attemptRes = await client.query(
`SELECT a.*, clock_timestamp() AS db_now FROM publishing_attempts a WHERE a.id = $1`,
[lease.attemptId],
);
const attempt = attemptRes.rows[0] as Row | undefined;
if (
attempt === undefined ||
attempt.status !== "in_progress" ||
attempt.owner_token !== lease.ownerToken ||
String(attempt.claim_epoch) !== lease.claimEpoch ||
toDate(attempt.lease_until) <= toDate(attempt.db_now)
) {
await end("ROLLBACK");
return { kind: "lost" };
}
if ((await readReceipt(client, lease.attemptId)) !== undefined) {
await end("ROLLBACK");
return { kind: "finalize" };
}
const scheduleRes = await client.query("SELECT * FROM scheduled_publications WHERE id = $1", [attempt.schedule_id]);
const draftRes = await client.query("SELECT * FROM drafts WHERE id = $1", [attempt.draft_id]);
const schedule = scheduleRes.rows[0] as Row;
const draft = draftRes.rows[0] as Row;
const fenced =
schedule.state === "in_flight" &&
String(schedule.generation) === String(attempt.schedule_generation) &&
String(schedule.idempotency_key) === String(attempt.idempotency_key) &&
contentFenceHolds(draft, attempt);
const denied = fenced ? await authorize(client, authzInput(attempt)) : undefined;
const priorDispatch = attempt.dispatch_started_at !== null;
const redispatchAllowed =
policy.allowRedispatch === true &&
attempt.provider_retry_deadline !== null &&
toDate(attempt.provider_retry_deadline) > toDate(attempt.db_now);
const exhausted = Number(attempt.dispatch_count) >= MAX_DISPATCHES;
if (!fenced || denied !== undefined || (priorDispatch && !redispatchAllowed) || exhausted) {
await end("ROLLBACK");
if (priorDispatch) {
const settled = await this.settle(lease, (c, l) => settleUnknown(c, l, "PUBLISH_OUTCOME_UNKNOWN"));
return settled.kind === "stale" ? { kind: "lost" } : { kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN" };
}
if (!fenced) {
const settled = await this.settle(lease, (c, l) => supersede(c, l, "SUPERSEDED").then(() => undefined));
return settled.kind === "stale" ? { kind: "lost" } : { kind: "superseded", code: "SUPERSEDED" };
}
if (denied !== undefined) {
const settled = await this.settle(lease, (c, l) => pause(c, l, denied));
return settled.kind === "stale" ? { kind: "lost" } : { kind: "paused", code: denied };
}
const settled = await this.settle(lease, (c, l) => settleFailed(c, l, "RETRY_EXHAUSTED"));
return settled.kind === "stale" ? { kind: "lost" } : { kind: "failed", code: "RETRY_EXHAUSTED" };
}
const windowSeconds = Math.max(0, (policy.dedupeWindowMs ?? 0) / 1000);
const stamped = await this.pool.connect().then(async (stampClient) => {
try {
await stampClient.query("BEGIN");
await stampClient.query("SELECT set_config('app.workspace_id', $1, true)", [lease.workspaceId]);
const res = await stampClient.query(
`UPDATE publishing_attempts SET
dispatch_started_at = clock_timestamp(),
first_dispatch_started_at = COALESCE(first_dispatch_started_at, clock_timestamp()),
dispatch_count = dispatch_count + 1,
provider_retry_deadline = COALESCE(provider_retry_deadline,
CASE WHEN $4::double precision > 0 THEN clock_timestamp() + make_interval(secs => $4::double precision) END)
WHERE id = $1 AND owner_token = $2 AND claim_epoch = $3 AND status = 'in_progress'
AND dispatch_count < ${MAX_DISPATCHES} AND lease_until > clock_timestamp()`,
[lease.attemptId, lease.ownerToken, lease.claimEpoch, windowSeconds],
);
await stampClient.query("COMMIT");
return (res.rowCount ?? 0) === 1;
} catch (err) {
await stampClient.query("ROLLBACK").catch(() => undefined);
throw err;
} finally {
stampClient.release();
}
});
if (!stamped) {
await end("ROLLBACK");
return { kind: "lost" };
}
try {
return { kind: "sent", value: await send() };
} finally {
await end("COMMIT");
}
} finally {
await end("ROLLBACK");
client.release();
}
}
// -------------------------------------------------------------------------
// Receipt + finalization
// -------------------------------------------------------------------------
/**
* Append-only provider evidence in its own transaction. Any (even deposed)
* owner may record matching evidence; a different remote id for the same
* attempt is an integrity failure, never an overwrite.
*/
async recordReceipt(
ref: { workspaceId: string; attemptId: string },
remote: RemoteResult,
): Promise<{ receipt: StoredReceipt; inserted: boolean }> {
const remoteId = remote.remoteId.trim();
if (remoteId === "" || remoteId.length > 512) {
throw new PublishingIntegrityError("RECEIPT_SHAPE", "provider returned an invalid remote id");
}
const metadata = remote.providerMetadata ?? {};
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
throw new PublishingIntegrityError("RECEIPT_SHAPE", "provider metadata must be an object");
}
return this.inTenant(ref.workspaceId, async (client) => {
const attempt = await client.query("SELECT dispatch_count FROM publishing_attempts WHERE id = $1", [ref.attemptId]);
const row = attempt.rows[0] as Row | undefined;
if (row === undefined) throw new PublishingIntegrityError("ATTEMPT_MISSING", "receipt for an unknown attempt");
if (Number(row.dispatch_count) === 0) {
throw new PublishingIntegrityError("RECEIPT_WITHOUT_DISPATCH", "receipt for an attempt that was never dispatched");
}
const ins = await client.query(
`INSERT INTO publishing_receipts (workspace_id, attempt_id, remote_id, remote_url, published_at, provider_metadata)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (workspace_id, attempt_id) DO NOTHING
RETURNING *`,
[ref.workspaceId, ref.attemptId, remoteId, remote.remoteUrl ?? null, remote.publishedAt ?? null, metadata],
);
if (ins.rows[0] !== undefined) return { receipt: receiptFromRow(ins.rows[0] as Row), inserted: true };
const existing = await readReceipt(client, ref.attemptId);
if (existing === undefined) throw new PublishingIntegrityError("RECEIPT_MISSING", "receipt conflict without a row");
if (existing.remoteId !== remoteId) {
throw new PublishingIntegrityError("RECEIPT_CONFLICT", `attempt ${ref.attemptId} already has different remote evidence`);
}
return { receipt: existing, inserted: false };
});
}
/**
* Current owner turns the receipt into exactly one canonical Publication:
* CAS owner/epoch, same generation/key, insert ON CONFLICT(key) then verify
* identity, mark done/completed/published, append audit last. A deposed
* owner gets `stale` and must not report success; its receipt stays for
* the new owner.
*/
async finalize(lease: Lease): Promise<FinalizeOutcome> {
return this.inTenant(lease.workspaceId, async (client) => {
const epoch = await lockWorkspace(client, lease.workspaceId);
const locked = await lockAttemptByKey(client, lease.idempotencyKey, epoch);
if (locked === undefined || !ownsLease(locked.attempt, lease)) return { kind: "stale" };
const { draft, schedule, attempt } = locked;
if (
String(schedule.generation) !== String(attempt.schedule_generation) ||
String(schedule.idempotency_key) !== String(attempt.idempotency_key) ||
String(draft.revision) !== String(attempt.approved_revision) ||
canonicalPostHash(draft.post as ExecutionPost) !== attempt.post_hash ||
canonicalPostHash(attempt.post_snapshot as ExecutionPost) !== attempt.post_hash
) {
throw new PublishingIntegrityError("FINALIZE_FENCE", `attempt ${lease.attemptId} fences changed while dispatched`);
}
const receipt = await readReceipt(client, lease.attemptId);
if (receipt === undefined) throw new PublishingIntegrityError("RECEIPT_MISSING", "finalize without a durable receipt");
await client.query(
`INSERT INTO publications
(workspace_id, draft_id, connection_id, idempotency_key, remote_id, remote_url, published_at, provider_metadata)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7::timestamptz, $8::timestamptz), $9)
ON CONFLICT (idempotency_key) DO NOTHING`,
[
lease.workspaceId,
attempt.draft_id,
attempt.connection_id,
attempt.idempotency_key,
receipt.remoteId,
receipt.remoteUrl ?? null,
receipt.publishedAt ?? null,
receipt.receivedAt,
receipt.providerMetadata,
],
);
const pubRes = await client.query("SELECT * FROM publications WHERE idempotency_key = $1", [attempt.idempotency_key]);
const publication = publicationFromRow(pubRes.rows[0] as Row);
if (
publication.remoteId !== receipt.remoteId ||
publication.draftId !== String(attempt.draft_id) ||
publication.connectionId !== String(attempt.connection_id)
) {
throw new PublishingIntegrityError("PUBLICATION_CONFLICT", `publication for ${String(attempt.idempotency_key)} disagrees with its receipt`);
}
await client.query(
`UPDATE publishing_attempts SET status = 'completed', owner_token = NULL, lease_until = NULL, error_code = NULL
WHERE id = $1`,
[attempt.id],
);
await client.query(
"UPDATE scheduled_publications SET state = 'done', error = NULL, error_code = NULL WHERE id = $1",
[schedule.id],
);
await client.query(
"UPDATE drafts SET state = 'published', updated_at = now() WHERE id = $1 AND state IN ('scheduled', 'approved')",
[draft.id],
);
const authorizationChanged = await authorize(client, authzInput(attempt));
await audit(client, lease.workspaceId, "publication.published", "publication", publication.id, {
...(authorizationChanged ? { authorizationChanged } : {}),
draftId: String(draft.id),
attemptNo: String(attempt.attempt_no),
remoteId: publication.remoteId,
});
return { kind: "done", publication };
});
}
// -------------------------------------------------------------------------
// Settlements (owner-CAS'd)
// -------------------------------------------------------------------------
/**
* Definite provider non-delivery (e.g. 429 with retry_after): same attempt
* and key, dispatch marker cleared, schedule pending at `notBefore`.
*/
async retryLater(lease: Lease, code: string, notBefore: Date): Promise<SettleOutcome> {
return this.retry(lease, code, notBefore, false);
}
/** Ambiguous deduplicated retry retains the original evidence and deadline. */
async retryDeduplicated(lease: Lease, code: string, notBefore: Date): Promise<SettleOutcome> {
return this.retry(lease, code, notBefore, true);
}
async pauseForReauthorization(lease: Lease, code = "CREDENTIAL_REJECTED"): Promise<SettleOutcome> {
return this.settle(lease, (client, locked) => pause(client, locked, code));
}
private async retry(lease: Lease, code: string, notBefore: Date, ambiguous: boolean): Promise<SettleOutcome> {
return this.settle(lease, async (client, locked) => {
if (Number(locked.attempt.dispatch_count) >= MAX_DISPATCHES || Number(locked.schedule.retry_count) >= MAX_DISPATCHES - 1) {
if (ambiguous) await settleUnknown(client, locked, "PUBLISH_OUTCOME_UNKNOWN");
else await settleFailed(client, locked, "RETRY_EXHAUSTED");
return undefined;
}
await client.query(
`UPDATE publishing_attempts SET status = 'ready', owner_token = NULL, lease_until = NULL,
dispatch_started_at = CASE WHEN $3::boolean THEN dispatch_started_at ELSE NULL END, error_code = $2
WHERE id = $1`,
[locked.attempt.id, code, ambiguous],
);
const res = await client.query(
`UPDATE scheduled_publications SET state = 'pending', next_attempt_at = GREATEST($2::timestamptz, now()),
retry_count = retry_count + 1, error_code = $3
WHERE id = $1 RETURNING next_attempt_at`,
[locked.schedule.id, notBefore, code],
);
await client.query("UPDATE drafts SET state = 'approved', updated_at = now() WHERE id = $1 AND state = 'scheduled'", [
locked.draft.id,
]);
return toDate((res.rows[0] as Row).next_attempt_at);
});
}
/** Permanent provider rejection or exhausted safe retries (known no-send). */
async fail(lease: Lease, code: string): Promise<SettleOutcome> {
return this.settle(lease, (client, locked) => settleFailed(client, locked, code));
}
/** Send may have happened and cannot be reconciled: lock, never resend. */
async markUnknown(lease: Lease, evidence?: { remoteId: string; scheduledAt: string }): Promise<SettleOutcome> {
return this.settle(lease, async (client, locked) => {
if (evidence) await audit(client, lease.workspaceId, "publication.unexpected_remote_schedule", "draft", lease.draftId, { remoteId: evidence.remoteId.slice(0, 512), scheduledAt: evidence.scheduledAt });
await settleUnknown(client, locked, "PUBLISH_OUTCOME_UNKNOWN");
});
}
private async settle(
lease: Lease,
fn: (client: PoolClient, locked: Locked) => Promise<Date | undefined | void>,
): Promise<SettleOutcome> {
return this.inTenant(lease.workspaceId, async (client) => {
const epoch = await lockWorkspace(client, lease.workspaceId);
const locked = await lockAttemptByKey(client, lease.idempotencyKey, epoch);
if (locked === undefined || !ownsLease(locked.attempt, lease)) return { kind: "stale" } as const;
const until = await fn(client, locked);
return until instanceof Date ? { kind: "settled", until } : { kind: "settled" };
});
}
private async takeOwnership(client: PoolClient, locked: Locked, epoch: string): Promise<Lease> {
const { attempt, schedule, draft } = locked;
const res = await client.query(
`UPDATE publishing_attempts SET status = 'in_progress', owner_token = gen_random_uuid(),
lease_until = now() + make_interval(secs => $3::double precision), authorized_epoch = $4, error_code = NULL
WHERE id = $1 AND claim_epoch = $2
RETURNING owner_token, claim_epoch, lease_until`,
[attempt.id, attempt.claim_epoch, this.leaseSeconds, epoch],
);
const row = res.rows[0] as Row | undefined;
if (row === undefined) throw new PublishingIntegrityError("CLAIM_CAS", "claim lost its compare-and-set under row lock");
await client.query(
"UPDATE scheduled_publications SET state = 'in_flight', authorized_epoch = $2, error = NULL, error_code = NULL WHERE id = $1",
[schedule.id, epoch],
);
await client.query("UPDATE drafts SET state = 'scheduled', updated_at = now() WHERE id = $1 AND state = 'approved'", [
draft.id,
]);
return {
workspaceId: String(attempt.workspace_id),
attemptId: String(attempt.id),
scheduleId: String(schedule.id),
draftId: String(draft.id),
idempotencyKey: String(attempt.idempotency_key),
generation: String(attempt.schedule_generation),
ownerToken: String(row.owner_token),
claimEpoch: String(row.claim_epoch),
leaseUntil: toDate(row.lease_until),
};
}
private async inTenant<T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK").catch(() => undefined);
throw err;
} finally {
client.release();
}
}
}
// ---------------------------------------------------------------------------
// Tenant-transaction helpers (caller holds the tenant context)
// ---------------------------------------------------------------------------
async function lockWorkspace(client: PoolClient, workspaceId: string): Promise<string> {
const res = await client.query("SELECT authz_epoch FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
const row = res.rows[0] as Row | undefined;
if (row === undefined) throw new PublishingIntegrityError("WORKSPACE_MISSING", "workspace not found");
return String(row.authz_epoch);
}
/** Locks draft -> schedule -> attempt for one key (workspace already locked). */
async function lockAttemptByKey(client: PoolClient, idempotencyKey: string, epoch: string): Promise<Locked | undefined> {
const ids = await client.query("SELECT id, schedule_id, draft_id FROM publishing_attempts WHERE idempotency_key = $1", [
idempotencyKey,
]);
const ref = ids.rows[0] as Row | undefined;
if (ref === undefined) return undefined;
const draft = (await client.query("SELECT * FROM drafts WHERE id = $1 FOR UPDATE", [ref.draft_id])).rows[0] as Row;
const schedule = (await client.query("SELECT * FROM scheduled_publications WHERE id = $1 FOR UPDATE", [ref.schedule_id]))
.rows[0] as Row;
const attempt = (await client.query("SELECT *, clock_timestamp() AS db_now FROM publishing_attempts WHERE id = $1 FOR UPDATE", [ref.id])).rows[0] as Row;
const connection = (
await client.query("SELECT id, provider, channel_id, fields, created_at FROM connections WHERE id = $1", [
attempt.connection_id,
])
).rows[0] as Row;
return { epoch, connection, draft, schedule, attempt };
}
function ownsLease(attempt: Row, lease: Lease): boolean {
return (
attempt.status === "in_progress" &&
attempt.owner_token === lease.ownerToken &&
String(attempt.claim_epoch) === lease.claimEpoch &&
toDate(attempt.lease_until) > toDate(attempt.db_now)
);
}
/**
* The approved snapshot may carry media, but its shape must still be exactly
* what the API admits at intake: 0..4 asset ids plus (optionally) an
* index-aligned list of alt texts. Asset EXISTENCE is not re-proven here — the
* api validated it against media_assets inside the workspace at intake, and
* the approved snapshot is immutable afterwards; the dispatch-time resolver
* re-derives the row (workspace-scoped) when it loads bytes. Anything else
* (wrong types, misaligned alt texts, remote media ids smuggled into
* providerMetadata) is a forged snapshot and refuses to schedule.
*/
function assertApprovedMediaShape(post: ExecutionPost): void {
const ids = post.mediaAssetIds;
if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string" && id.length > 0)) {
throw new PublishingStateError("UNSUPPORTED_MEDIA", "post.mediaAssetIds must be an array of non-empty strings");
}
if (ids.length > MAX_MEDIA_ATTACHMENTS) {
throw new PublishingStateError(
"UNSUPPORTED_MEDIA",
`post.mediaAssetIds supports at most ${MAX_MEDIA_ATTACHMENTS} attachments`,
);
}
const altTexts = post.altTexts;
if (altTexts !== undefined) {
if (!Array.isArray(altTexts) || !altTexts.every((entry) => entry === null || typeof entry === "string")) {
throw new PublishingStateError("UNSUPPORTED_MEDIA", "post.altTexts must be an array of strings or nulls");
}
if (altTexts.length !== ids.length) {
throw new PublishingStateError("UNSUPPORTED_MEDIA", "post.altTexts must align with post.mediaAssetIds (same length)");
}
}
if (Object.prototype.hasOwnProperty.call(post.providerMetadata, "mediaIds")) {
// Remote media ids bypass asset ownership; the runtime refuses them at
// dispatch too (idempotency.FORBIDDEN_POST_METADATA_KEYS).
throw new PublishingStateError("UNSUPPORTED_MEDIA", "providerMetadata.mediaIds is reserved and may not be supplied by the author");
}
}
function contentFenceHolds(draft: Row, attempt: Row): boolean {
return (
String(draft.revision) === String(attempt.approved_revision) &&
String(draft.approved_revision) === String(attempt.approved_revision) &&
draft.approved_hash === attempt.post_hash &&
canonicalPostHash(draft.post as ExecutionPost) === attempt.post_hash &&
canonicalPostHash(attempt.post_snapshot as ExecutionPost) === attempt.post_hash &&
draft.reviewer_user_id === attempt.reviewer_user_id &&
String(draft.id) === String(attempt.draft_id)
);
}
interface AuthzInput {
workspaceId: string;
principal: string;
permission: string;
reviewer: string;
connectionId: string;
credentialId: string;
credentialGeneration: string;
}
function authzInput(attempt: Row): AuthzInput {
return {
workspaceId: String(attempt.workspace_id),
principal: String(attempt.scheduled_by_user_id),
permission: String(attempt.required_permission),
reviewer: String(attempt.reviewer_user_id),
connectionId: String(attempt.connection_id),
credentialId: String(attempt.credential_id),
credentialGeneration: String(attempt.credential_generation),
};
}
/**
* Re-reads every live authorization input: the recorded principal's active
* membership + role permission + connection access (userCanUseConnection),
* the approver's live approval.grant, the connection (not a tombstone) and
* the exact credential row/generation, unrevoked. Never a stored role.
*/
async function authorize(client: PoolClient, input: AuthzInput): Promise<PublishErrorCode | undefined> {
const res = await client.query(
`SELECT
EXISTS (
SELECT 1 FROM memberships m
JOIN role_permissions rp ON rp.role = m.role AND rp.permission = $3
WHERE m.workspace_id = $1 AND m.user_id = $2 AND m.status = 'active'
AND (m.role = ANY($8::text[])
OR EXISTS (SELECT 1 FROM group_connections gc
JOIN group_members gm ON gm.group_id = gc.group_id
WHERE gc.connection_id = $5 AND gm.user_id = m.user_id))
) AS principal_ok,
EXISTS (
SELECT 1 FROM memberships m
JOIN role_permissions rp ON rp.role = m.role AND rp.permission = 'approval.grant'
WHERE m.workspace_id = $1 AND m.user_id = $4 AND m.status = 'active'
) AS approver_ok,
EXISTS (
SELECT 1 FROM connections c
JOIN credentials cr ON cr.connection_id = c.id
WHERE c.id = $5 AND c.disconnected_at IS NULL
AND cr.id = $6 AND cr.kind = 'connector-oauth' AND cr.generation = $7 AND cr.revoked_at IS NULL
) AS credential_ok`,
[
input.workspaceId,
input.principal,
input.permission,
input.reviewer,
input.connectionId,
input.credentialId,
input.credentialGeneration,
WORKSPACE_WIDE_ROLES,
],
);
const row = res.rows[0] as { principal_ok: boolean; approver_ok: boolean; credential_ok: boolean };
if (!row.credential_ok) return "CREDENTIAL_CHANGED";
if (!row.principal_ok || !row.approver_ok) return "AUTHZ_REVOKED";
return undefined;
}
/** Durable pause: the attempt is closed; resume is a new explicit attempt. */
async function pause(client: PoolClient, locked: Locked, code: PublishErrorCode): Promise<void> {
await client.query(
"UPDATE publishing_attempts SET status = 'abandoned', owner_token = NULL, lease_until = NULL, error_code = $2 WHERE id = $1",
[locked.attempt.id, code],
);
await client.query(
`UPDATE scheduled_publications SET state = 'needs_reauthorization', error_code = $3
WHERE id = $1 AND generation = $2`,
[locked.schedule.id, locked.attempt.schedule_generation, code],
);
await client.query("UPDATE drafts SET state = 'approved', updated_at = now() WHERE id = $1 AND state = 'scheduled'", [
locked.draft.id,
]);
await audit(client, String(locked.attempt.workspace_id), "publication.paused", "draft", String(locked.draft.id), {
code,
attemptNo: String(locked.attempt.attempt_no),
});
}
/**
* Old generation / changed content / cancelled: abandon the undispatched
* attempt; invalidate the schedule only if its generation still matches. A
* newer draft or schedule is never overwritten.
*/
async function supersede(client: PoolClient, locked: Locked, code: PublishErrorCode): Promise<ClaimOutcome> {
const { attempt, schedule } = locked;
if (attempt.dispatch_started_at !== null) {
await settleUnknown(client, locked, "PUBLISH_OUTCOME_UNKNOWN");
return { kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN" };
}
if (attempt.status === "ready" || attempt.status === "in_progress") {
await client.query(
"UPDATE publishing_attempts SET status = 'abandoned', owner_token = NULL, lease_until = NULL, error_code = $2 WHERE id = $1",
[attempt.id, code],
);
}
if (String(schedule.generation) === String(attempt.schedule_generation) && ["pending", "in_flight"].includes(String(schedule.state))) {
await client.query(
"UPDATE scheduled_publications SET state = 'cancelled', error_code = $2 WHERE id = $1",
[schedule.id, code],
);
await client.query("UPDATE drafts SET state = 'approved', updated_at = now() WHERE id = $1 AND state = 'scheduled'", [
locked.draft.id,
]);
}
await audit(client, String(attempt.workspace_id), "publication.superseded", "draft", String(attempt.draft_id), {
code,
attemptNo: String(attempt.attempt_no),
});
return { kind: "superseded", code };
}
async function settleFailed(client: PoolClient, locked: Locked, code: string): Promise<void> {
await client.query(
"UPDATE publishing_attempts SET status = 'failed', owner_token = NULL, lease_until = NULL, error_code = $2 WHERE id = $1",
[locked.attempt.id, code],
);
await client.query("UPDATE scheduled_publications SET state = 'failed', error_code = $2 WHERE id = $1", [
locked.schedule.id,
code,
]);
await client.query("UPDATE drafts SET state = 'approved', updated_at = now() WHERE id = $1 AND state = 'scheduled'", [
locked.draft.id,
]);
await audit(client, String(locked.attempt.workspace_id), "publication.failed", "draft", String(locked.draft.id), {
code,
attemptNo: String(locked.attempt.attempt_no),
});
}
/** Unknown outcome keeps the draft scheduled (locked) and never resends automatically. */
async function settleUnknown(client: PoolClient, locked: Locked, code: string): Promise<void> {
await client.query(
"UPDATE publishing_attempts SET status = 'unknown', owner_token = NULL, lease_until = NULL, error_code = $2 WHERE id = $1",
[locked.attempt.id, code],
);
await client.query("UPDATE scheduled_publications SET state = 'failed', error_code = $2 WHERE id = $1", [
locked.schedule.id,
code,
]);
await audit(client, String(locked.attempt.workspace_id), "publication.outcome_unknown", "draft", String(locked.draft.id), {
code,
attemptNo: String(locked.attempt.attempt_no),
});
}
async function audit(
client: PoolClient,
workspaceId: string,
action: string,
targetType: string,
targetId: string,
diff: Record<string, unknown>,
): Promise<void> {
await client.query("SELECT audit_append($1, NULL, $2, $3, $4, $5, $6)", [
workspaceId,
SCHEDULER_ROLE,
action,
targetType,
targetId,
diff,
]);
}
async function readReceipt(client: PoolClient, attemptId: string): Promise<StoredReceipt | undefined> {
const res = await client.query("SELECT * FROM publishing_receipts WHERE attempt_id = $1", [attemptId]);
const row = res.rows[0] as Row | undefined;
return row === undefined ? undefined : receiptFromRow(row);
}
async function dbNow(client: PoolClient): Promise<Date> {
const res = await client.query("SELECT clock_timestamp() AS now");
return toDate((res.rows[0] as Row).now);
}
// ---------------------------------------------------------------------------
// Row mapping
// ---------------------------------------------------------------------------
function jobFromLocked(locked: Locked, lease: Lease): ClaimedJob {
return {
lease,
retryCount: Number(locked.schedule.retry_count),
...(locked.attempt.first_dispatch_started_at == null ? {} : { firstDispatchStartedAt: toDate(locked.attempt.first_dispatch_started_at) }),
...(locked.attempt.provider_retry_deadline == null ? {} : { providerRetryDeadline: toDate(locked.attempt.provider_retry_deadline) }),
attemptNo: String(locked.attempt.attempt_no),
post: executionPost(locked.attempt.post_snapshot as Row, String(locked.attempt.draft_id), lease.idempotencyKey),
connection: {
id: String(locked.connection.id),
provider: String(locked.connection.provider),
channelId: String(locked.connection.channel_id),
fields: (locked.connection.fields ?? {}) as Record<string, unknown>,
createdAt: toDate(locked.connection.created_at),
},
credential: { id: String(locked.attempt.credential_id), generation: String(locked.attempt.credential_generation) },
priorDispatch:
locked.attempt.dispatch_started_at === null
? undefined
: {
startedAt: toDate(locked.attempt.dispatch_started_at),
count: Number(locked.attempt.dispatch_count),
...(locked.attempt.provider_retry_deadline === null
? {}
: { providerRetryDeadline: toDate(locked.attempt.provider_retry_deadline) }),
},
};
}
/**
* The immutable approved snapshot as the connector sees it: pipeline-owned
* timing (no scheduledAt) and server-injected reserved metadata that a user
* cannot pre-set.
*/
function executionPost(snapshot: Row, draftId: string, idempotencyKey: string): ExecutionPost {
const metadata = snapshot.providerMetadata;
const mediaAssetIds = Array.isArray(snapshot.mediaAssetIds) ? snapshot.mediaAssetIds.map((v) => String(v)) : [];
const rawAltTexts = snapshot.altTexts;
const altTexts =
Array.isArray(rawAltTexts) && rawAltTexts.length === mediaAssetIds.length
? rawAltTexts.map((v) => (v === null || v === undefined ? null : typeof v === "string" ? v : null))
: undefined;
return {
text: String(snapshot.text ?? ""),
mediaAssetIds,
...(altTexts === undefined ? {} : { altTexts }),
visibility: String(snapshot.visibility ?? "provider_default"),
providerMetadata: {
...(typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) ? (metadata as Row) : {}),
draftId,
idempotencyKey,
},
};
}
function publicationFromRow(row: Row): StoredPublication {
return {
id: String(row.id),
workspaceId: String(row.workspace_id),
draftId: String(row.draft_id),
connectionId: String(row.connection_id),
idempotencyKey: String(row.idempotency_key),
remoteId: String(row.remote_id),
...(row.remote_url === null || row.remote_url === undefined ? {} : { remoteUrl: String(row.remote_url) }),
publishedAt: toDate(row.published_at),
providerMetadata: (row.provider_metadata ?? {}) as Record<string, unknown>,
};
}
function receiptFromRow(row: Row): StoredReceipt {
return {
id: String(row.id),
workspaceId: String(row.workspace_id),
attemptId: String(row.attempt_id),
remoteId: String(row.remote_id),
...(row.remote_url === null || row.remote_url === undefined ? {} : { remoteUrl: String(row.remote_url) }),
...(row.published_at === null || row.published_at === undefined ? {} : { publishedAt: toDate(row.published_at) }),
providerMetadata: (row.provider_metadata ?? {}) as Record<string, unknown>,
receivedAt: toDate(row.received_at),
};
}
function toDate(value: unknown): Date {
return value instanceof Date ? value : new Date(String(value));
}
function maxDate(a: Date, b: Date): Date {
return a.getTime() >= b.getTime() ? a : b;
}