mattermost-mobile-upstream/app/actions/remote/draft.ts
Guillermo Vayá 73488f0b4b fix(drafts): close 3 residual gaps in Phase-4 review fixes (#1, #2, #6)
Re-verification of the 11 Phase-4 review fixes surfaced 3 that were only
partially closed:

#2 (busy loop): drainOutbox skipped a mis-scoped teamId='' row with a bare
`continue`, leaving it Pending-and-due. scheduleWork kept counting it as due
work and re-armed a zero-delay drain that skipped it again — a CPU spin. Now
the skip backs off next_attempt_at (via computeNextAttemptAt), so the row
leaves the due set until its channel/membership may hydrate; a valid drain
resets the count.

#1 (torn-down DB write): the GET/reconcile path wrote the snapshot after the
HTTP await with no post-HTTP guard — only the POST/DELETE workers had one.
reconcileTeamDrafts now takes ReconcileOpts.shouldAbort and re-checks
shouldAbortPostHttpWrite before every per-draft write, failing closed if the
server was torn down mid-GET.

#6 (observation fence too late): the in-flight observation ordinal was bumped
only after reconcileTeamDrafts fully returned (after the whole apply loop),
leaving a window where a concurrent POST ack cleared the outbox first. The
bump now runs synchronously via ReconcileOpts.onSnapshot the moment the GET
resolves, before any apply-loop await.

Adds regression tests for each; all affected suites green (tsc + eslint clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 12:34:56 +02:00

872 lines
39 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q, type Database, type Model} from '@nozbe/watermelondb';
import {General} from '@constants';
import {MM_TABLES} from '@constants/database';
import {DRAFT_SYNC_RETRY_BASE_MS, DRAFT_SYNC_RETRY_JITTER, DRAFT_SYNC_RETRY_MAX_MS, DraftOutboxOperation, DraftOutboxStatus} from '@constants/draft';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import websocketManager from '@managers/websocket_manager';
import {getChannelById, getMyChannel} from '@queries/servers/channel';
import {adoptLegacyDrafts, buildDraftOutboxId, getDraft, getDraftOutbox, mutateDraftAndOutbox, prepareDraftOutbox, queryDraftsForTeam, type PrepareDraftAndOutbox} from '@queries/servers/drafts';
import {getConfigValue} from '@queries/servers/system';
import {buildDraftUpsertRequest, draftContentFingerprint, normalizeServerDraft, type NormalizedDraft} from '@utils/draft/sync';
import {getFullErrorMessage, isErrorWithStatusCode} from '@utils/errors';
import {logDebug, logError} from '@utils/log';
import {forceLogoutIfNecessary} from './session';
import type DraftModel from '@typings/database/models/servers/draft';
import type DraftOutboxModel from '@typings/database/models/servers/draft_outbox';
const {SERVER: {DRAFT, DRAFT_OUTBOX}} = MM_TABLES;
/**
* fetchDraftsForTeam: fetch the server's authoritative draft snapshot for a team. Returns the raw
* DraftApi list on success. On failure it triggers a forced logout when the error warrants it,
* logs a non-PII error, and returns {error} so callers can fail closed (apply nothing).
*/
export async function fetchDraftsForTeam(serverUrl: string, teamId: string, groupLabel?: RequestGroupLabel): Promise<{drafts?: DraftApi[]; error?: unknown}> {
try {
const client = NetworkManager.getClient(serverUrl);
const drafts = await client.getDrafts(teamId, groupLabel);
return {drafts};
} catch (error) {
logError('fetchDraftsForTeam', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
}
/**
* applyServerMetadata: produce the metadata to persist for a remote update. Server-owned fields
* (priority and the normalized burn-on-read borConfig) come from the snapshot; only genuinely
* device-local metadata (markdown image dimensions) is preserved from the existing row. This keeps
* type and borConfig consistent: a normal->BoR update carries the reconstructed borConfig (or none
* when it fails closed), and a BoR->normal update drops a stale enabled borConfig so it can never
* be sent as burn-on-read. `sd.metadata.borConfig` is already reconstructed fail-closed upstream.
*/
const applyServerMetadata = (existing: PostMetadata | undefined, sd: NormalizedDraft): PostMetadata => {
const next: PostMetadata = {};
// Device-local, non-portable metadata is preserved.
if (existing?.images) {
next.images = existing.images;
}
// Server-owned metadata replaces whatever was local.
if (sd.metadata?.priority) {
next.priority = sd.metadata.priority;
}
if (sd.metadata?.borConfig) {
next.borConfig = sd.metadata.borConfig;
}
return next;
};
/**
* prepareIncomingDraft: the additive reconciliation matrix for a single draft key. Given the current
* local Draft/DraftOutbox rows (fetched inside the serialized writer) and the normalized server draft
* `sd`, it returns the prepared records to commit. It NEVER deletes a Draft for being absent from the
* server and NEVER performs a network operation — this is a READ-path snapshot applier only.
*/
const prepareIncomingDraft = (sd: NormalizedDraft): PrepareDraftAndOutbox => ({database, channelId, rootId, draft, outbox}) => {
const createCleanDraft = (): Model => database.collections.get<DraftModel>(DRAFT).prepareCreate((d) => {
d.channelId = channelId;
d.rootId = rootId;
d.message = sd.message;
d.type = sd.type;
d.props = sd.props;
d.fileIds = sd.fileIds;
d.files = sd.files;
d.metadata = sd.metadata;
d.serverUpdateAt = sd.serverUpdateAt;
d.updateAt = Date.now();
});
const updateCleanDraft = (existing: DraftModel): Model => existing.prepareUpdate((d) => {
d.message = sd.message;
d.type = sd.type;
d.props = sd.props;
d.fileIds = sd.fileIds;
d.files = sd.files;
d.metadata = applyServerMetadata(d.metadata, sd);
d.serverUpdateAt = sd.serverUpdateAt;
d.updateAt = Date.now();
});
// A queued DELETE is authoritative local intent: the fingerprint distinguishes a stale replica
// echo of the very content we are deleting from genuinely new content written by another client.
if (outbox && outbox.operation === DraftOutboxOperation.Delete) {
const fp = draftContentFingerprint({
message: sd.message,
type: sd.type,
props: sd.props,
fileIds: sd.fileIds,
priority: sd.metadata?.priority,
});
if (fp === outbox.deletedFingerprint) {
// The server still holds the exact content we are deleting. For a Pending delete (not yet
// sent) that is expected — keep it pending; the drain will DELETE it. For a ConfirmingDelete
// (we already got a 200 but the content is still present) the delete did NOT take, so reset
// to Pending and let the drain retry the DELETE (naturally spaced by the confirmation GETs).
if (outbox.status === DraftOutboxStatus.ConfirmingDelete) {
return [outbox.prepareUpdate((o) => {
o.status = DraftOutboxStatus.Pending;
o.attemptCount = 0;
o.nextAttemptAt = 0;
})];
}
return [];
}
// The server content differs from what we enqueued for deletion — another client wrote new
// content after our delete.
if (outbox.keepLocal) {
// keep_local: the retained empty/attachment-only local Draft must be neither resurrected
// nor replaced with remote text. Abandon the DELETE by parking it as unsyncable_empty so
// later reconciliations preserve the local Draft (and it never POSTs). Do NOT touch the Draft.
return prepareDraftOutbox(database, channelId, rootId, outbox.teamId, outbox, {type: 'park'});
}
// Ordinary delete: abandon it and adopt the new server content as a clean local draft.
return [
outbox.prepareDestroyPermanently(),
draft ? updateCleanDraft(draft) : createCleanDraft(),
];
}
// A pending UPSERT is unsynced local intent that wins: keep local message/files/type/priority and
// only merge the server-owned passthrough props while recording the observed server_update_at.
if (outbox && outbox.operation === DraftOutboxOperation.Upsert && outbox.status === DraftOutboxStatus.Pending) {
if (!draft) {
return [];
}
return [draft.prepareUpdate((d) => {
d.props = sd.props;
d.serverUpdateAt = sd.serverUpdateAt;
})];
}
// An in-progress/failed upload: apply the remote portable fields but preserve the local `files`
// array (which carries upload progress/failure state) untouched.
if (outbox && (outbox.status === DraftOutboxStatus.WaitingForUpload || outbox.status === DraftOutboxStatus.BlockedUpload)) {
if (!draft) {
return [];
}
return [draft.prepareUpdate((d) => {
d.message = sd.message;
d.type = sd.type;
d.props = sd.props;
d.fileIds = sd.fileIds;
d.metadata = applyServerMetadata(d.metadata, sd);
d.serverUpdateAt = sd.serverUpdateAt;
d.updateAt = Date.now();
})];
}
// A parked unsyncable-empty draft: preserve it locally, ignore the server content.
if (outbox && outbox.status === DraftOutboxStatus.Blocked) {
return [];
}
// No outbox and no local draft: create the clean draft from the server snapshot.
if (!draft) {
return [createCleanDraft()];
}
// No outbox and a clean existing draft: the server snapshot is authoritative. Apply only when the
// portable content actually differs (idempotent otherwise); an equal server_update_at with a
// differing payload still applies.
const localFingerprint = draftContentFingerprint({
message: draft.message,
type: draft.type,
props: draft.props,
fileIds: draft.fileIds ?? [],
priority: draft.metadata?.priority,
});
const serverFingerprint = draftContentFingerprint({
message: sd.message,
type: sd.type,
props: sd.props,
fileIds: sd.fileIds,
priority: sd.metadata?.priority,
});
if (localFingerprint === serverFingerprint) {
return [];
}
return [updateCleanDraft(draft)];
};
/**
* reconcileTeamDrafts: apply the server's authoritative draft snapshot to local state additively.
* It adopts legacy drafts first (so local intent wins below), fetches the snapshot, and applies the
* incoming matrix per in-scope key. It NEVER deletes a Draft for being absent from the response, and
* it applies nothing when the GET fails (no baseline, no deletion). Returns the applied write count
* and the in-scope normalized drafts so a later absence-detection sub-step can track candidates.
*/
export async function reconcileTeamDrafts(serverUrl: string, teamId: string, opts?: ReconcileOpts): Promise<{applied?: number; drafts?: NormalizedDraft[]; missingDeps?: boolean; error?: unknown}> {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
// Adopt pre-sync drafts FIRST so their local intent wins over the incoming snapshot below.
await adoptLegacyDrafts(database, teamId);
const res = await fetchDraftsForTeam(serverUrl, teamId);
if (res.error || !res.drafts) {
// GET failure: apply nothing. Without a baseline we must not create/update/delete anything.
return {error: res.error};
}
// Post-dispatch observation fence: announce every observed key SYNCHRONOUSLY, in the same tick
// the GET resolved and before any apply-loop await, so an in-flight POST's ack (which may run
// during that loop) sees the bumped ordinal and does not clobber content this GET observed.
opts?.onSnapshot?.(res.drafts.map((d) => ({channelId: d.channel_id, rootId: d.root_id})));
// BoR durations live in server config, not in draft props. Read them once; fail closed to
// undefined when unavailable so burn-on-read reconstruction never fabricates durations.
const borDurationSeconds = parseInt(await getConfigValue(database, 'BurnOnReadDurationSeconds') ?? '', 10);
const borMaximumTimeToLiveSeconds = parseInt(await getConfigValue(database, 'BurnOnReadMaximumTimeToLiveSeconds') ?? '', 10);
const durations = (borDurationSeconds > 0 && borMaximumTimeToLiveSeconds > 0) ? {borDurationSeconds, borMaximumTimeToLiveSeconds} : undefined;
const inScope: NormalizedDraft[] = [];
let applied = 0;
let missingChannelCount = 0;
for (const serverDraft of res.drafts) {
// The GET awaited above may have outlived the server: a logout/wipe/persistence-mode change
// can have destroyed or recreated the database mid-flight. Re-check before every snapshot
// write so a late completion never writes to a stale/destroyed database (the manager's own
// epoch check only runs AFTER this function returns — too late for these in-loop writes).
if (shouldAbortPostHttpWrite(serverUrl, database, opts)) {
return {error: 'reconcileTeamDrafts: server torn down during reconcile'};
}
const sd = normalizeServerDraft(serverDraft, durations);
// eslint-disable-next-line no-await-in-loop
const channel = await getChannelById(database, sd.channelId);
if (!channel) {
// Missing channel row: skip this key without aborting the others and signal a
// guaranteed retry (missingDeps) so a later reschedule reconciles it once the channel
// is hydrated. NEVER log the channel id — DM/GM channel ids embed usernames (PII).
missingChannelCount += 1;
continue;
}
const isDmGm = channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL;
// Authoritative scope: a confirmed membership row is required in every case (a DM/GM the
// user is not a member of is not authoritative). A non-DM/GM channel must additionally
// belong to the reconciled team — a channel mis-scoped to another team is not this run's
// authority — so it is skipped.
// eslint-disable-next-line no-await-in-loop
const isMember = Boolean(await getMyChannel(database, sd.channelId));
const inScopeKey = isDmGm ? isMember : (isMember && channel.teamId === teamId);
if (!inScopeKey) {
continue;
}
inScope.push(sd);
// eslint-disable-next-line no-await-in-loop
const models = await mutateDraftAndOutbox(database, sd.channelId, sd.rootId, prepareIncomingDraft(sd));
if (models.length > 0) {
applied += 1;
}
}
if (missingChannelCount > 0) {
// Count only, no ids — the skip stays traceable without logging PII.
logDebug('reconcileTeamDrafts: skipped drafts for missing channels', missingChannelCount);
}
return {applied, drafts: inScope, missingDeps: missingChannelCount > 0};
} catch (error) {
logError('reconcileTeamDrafts', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
}
/**
* ReconcileKey: the classification payload for a single in-scope draft key considered by the
* manager's absence pass. `kind` distinguishes a plain Draft row from a delete tombstone (which
* drives the decision when both a Draft and a delete outbox exist for the key). `authoritative`
* is false when the channel membership cannot be confirmed for this scope (membership lost), in
* which case the key must be preserved (never deleted).
*/
export type ReconcileKey = {
channelId: string;
rootId: string;
kind: 'draft' | 'tombstone';
serverUpdateAt: number;
hasOutbox: boolean;
outboxOperation?: DraftOutboxOperation;
outboxStatus?: DraftOutboxStatus;
keepLocal?: boolean;
authoritative: boolean;
};
/**
* isChannelAuthoritative: a channel is authoritative for absence decisions when it is a DM/GM
* (always in scope) or the current user has a confirmed membership row for it. A non-DM/GM channel
* whose membership row is gone is NOT authoritative — its keys are preserved, never deleted.
*/
const isChannelAuthoritative = async (database: Database, channelId: string): Promise<boolean> => {
const channel = await getChannelById(database, channelId);
if (channel?.type === General.DM_CHANNEL || channel?.type === General.GM_CHANNEL) {
return true;
}
return Boolean(await getMyChannel(database, channelId));
};
/**
* getReconcilableKeys: enumerate every in-scope draft key (a Draft row and/or a delete tombstone)
* that the manager's absence pass may need to classify for `teamId`. Delete tombstones drive the
* decision, so a key that has both a Draft and a delete tombstone appears once as a `tombstone`.
* Only tombstones within this run's authority are returned: those whose stored teamId matches, or
* DM/GM ('' team) tombstones whose channel is a confirmed DM/GM channel. This function performs no
* network activity and never mutates state.
*/
export async function getReconcilableKeys(database: Database, teamId: string): Promise<ReconcileKey[]> {
const keys: ReconcileKey[] = [];
const tombstoneKeys = new Set<string>();
const deleteOutboxes = await database.collections.get<DraftOutboxModel>(DRAFT_OUTBOX).query(
Q.where('operation', DraftOutboxOperation.Delete),
).fetch();
for (const outbox of deleteOutboxes) {
// eslint-disable-next-line no-await-in-loop
const channel = await getChannelById(database, outbox.channelId);
const isDmGm = channel?.type === General.DM_CHANNEL || channel?.type === General.GM_CHANNEL;
// Scope gate: this run only has authority over tombstones stored under its team, or DM/GM
// ('' team) tombstones whose channel is a confirmed DM/GM channel. Skip everything else.
if (outbox.teamId !== teamId && !(outbox.teamId === '' && isDmGm)) {
continue;
}
// eslint-disable-next-line no-await-in-loop
const authoritative = isDmGm || Boolean(await getMyChannel(database, outbox.channelId));
tombstoneKeys.add(buildDraftOutboxId(outbox.channelId, outbox.rootId));
keys.push({
channelId: outbox.channelId,
rootId: outbox.rootId,
kind: 'tombstone',
serverUpdateAt: 0,
hasOutbox: true,
outboxOperation: outbox.operation,
outboxStatus: outbox.status,
keepLocal: outbox.keepLocal,
authoritative,
});
}
const drafts = await queryDraftsForTeam(database, teamId).fetch();
for (const draft of drafts) {
const key = buildDraftOutboxId(draft.channelId, draft.rootId);
if (tombstoneKeys.has(key)) {
// A delete tombstone already represents this key (a keepLocal=true delete retains a Draft).
continue;
}
// eslint-disable-next-line no-await-in-loop
const outbox = await getDraftOutbox(database, draft.channelId, draft.rootId);
// eslint-disable-next-line no-await-in-loop
const authoritative = await isChannelAuthoritative(database, draft.channelId);
keys.push({
channelId: draft.channelId,
rootId: draft.rootId,
kind: 'draft',
serverUpdateAt: draft.serverUpdateAt ?? 0,
hasOutbox: Boolean(outbox),
outboxOperation: outbox?.operation,
outboxStatus: outbox?.status,
keepLocal: outbox?.keepLocal,
authoritative,
});
}
return keys;
}
/**
* deleteAbsentCleanDraft: CONFIRMED-absence deletion of a clean, server-backed Draft (Rule A). It
* re-reads the key inside the serialized writer and only destroys the Draft when it is STILL clean
* (serverUpdateAt > 0 AND no outbox) — otherwise the state changed since the decision and it no-ops.
* Never performs a network operation.
*/
export async function deleteAbsentCleanDraft(serverUrl: string, channelId: string, rootId: string): Promise<void> {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
try {
await mutateDraftAndOutbox(database, channelId, rootId, ({draft, outbox}) => {
if (draft && !outbox && (draft.serverUpdateAt ?? 0) > 0) {
return [draft.prepareDestroyPermanently()];
}
return [];
});
} catch (error) {
logError('deleteAbsentCleanDraft', getFullErrorMessage(error));
}
}
/**
* confirmDeleteTombstone: CONFIRMED-absence resolution of a delete tombstone (Rule B). It re-reads
* the key inside the serialized writer; if the outbox is no longer a Delete (a genuine edit flipped
* it) it no-ops. For an ordinary delete (keepLocal=false) it removes the tombstone row (the visible
* Draft was already removed at enqueue time). For a keepLocal=true delete it detaches the retained
* Draft from the server (serverUpdateAt=0) and parks the outbox as unsyncable_empty. Never networks.
*/
export async function confirmDeleteTombstone(serverUrl: string, channelId: string, rootId: string): Promise<void> {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
try {
await mutateDraftAndOutbox(database, channelId, rootId, ({database: db, draft, outbox}) => {
if (!outbox || outbox.operation !== DraftOutboxOperation.Delete) {
return [];
}
if (outbox.status !== DraftOutboxStatus.Pending && outbox.status !== DraftOutboxStatus.ConfirmingDelete) {
return [];
}
if (!outbox.keepLocal) {
// Ordinary delete: the visible Draft was already removed at enqueue time. If a Draft
// unexpectedly exists, leave it — only drop the tombstone row.
return prepareDraftOutbox(db, channelId, rootId, outbox.teamId, outbox, {type: 'remove'});
}
const models: Model[] = [];
if (draft) {
models.push(draft.prepareUpdate((d) => {
d.serverUpdateAt = 0;
}));
}
models.push(...prepareDraftOutbox(db, channelId, rootId, outbox.teamId, outbox, {type: 'park'}));
return models;
});
} catch (error) {
logError('confirmDeleteTombstone', getFullErrorMessage(error));
}
}
/**
* WorkerOutcome: the result of a single per-key outbox worker run.
* - done: nothing left to do for this generation (removed / acked / no-op).
* - retry: transient failure or a newer generation remains; the row stays time-driven.
* - blocked: parked awaiting an external signal (edit/unblock); not time-driven.
* - suspend: a server-wide failure (sync disabled / unsupported route); stop this session.
* - converted: the upsert was rewritten into a delete; drain will pick the delete up next.
*/
export type WorkerOutcome = {
outcome: 'done' | 'retry' | 'blocked' | 'suspend' | 'converted';
retryAfterMs?: number;
};
/**
* OutboxWorkerOpts: caller-supplied guards a worker consults ONLY after its HTTP call resolves and
* immediately before any post-HTTP database write.
* - shouldAbort: true when the server lifecycle changed under the request (epoch bumped / server
* invalidated); the worker must not write.
* - captureObservation / observationChanged: the per-key observation ordinal fence. captureObservation
* is read right before the POST dispatch; observationChanged(captured) is true in the ack when a
* reconcile GET observed newer content for this key while the POST was in flight, so the ack must
* not clobber it (see the manager's observationOrdinals).
*/
export type OutboxWorkerOpts = {
shouldAbort?: () => boolean;
captureObservation?: () => number;
observationChanged?: (captured: number) => boolean;
};
/**
* ReconcileOpts: caller-supplied guards for the GET/reconcile path (see reconcileTeamDrafts).
* - shouldAbort: same semantics as OutboxWorkerOpts.shouldAbort — true when the server lifecycle
* changed under the in-flight GET, so no snapshot may be written to the (now stale) database.
* - onSnapshot: invoked SYNCHRONOUSLY the moment the GET response is received (before any apply-loop
* await), with every observed key. The manager uses it to bump the per-key observation ordinal for
* in-flight POSTs, so a concurrent ack cannot clobber content this GET already observed.
*/
export type ReconcileOpts = {
shouldAbort?: () => boolean;
onSnapshot?: (keys: Array<{channelId: string; rootId: string}>) => void;
};
/**
* shouldAbortPostHttpWrite: guards every post-HTTP database write. Returns true (abort, write nothing)
* when the caller signals staleness OR the server database is no longer the SAME instance captured at
* the top of the worker (recreated by a logout/wipe/re-login) or is now absent. A late HTTP completion
* must never write to a destroyed/recreated database.
*/
const shouldAbortPostHttpWrite = (serverUrl: string, database: Database, opts?: {shouldAbort?: () => boolean}): boolean => {
if (opts?.shouldAbort?.()) {
return true;
}
return DatabaseManager.serverDatabases[serverUrl]?.database !== database;
};
// Non-sensitive outbox error classifications persisted to last_error_code.
const OUTBOX_ERROR = {
MissingLocalDraft: 'missing_local_draft',
Forbidden: 'forbidden',
UnsupportedRoute: 'unsupported_route',
SyncDisabled: 'sync_disabled',
Invalid: 'invalid',
ScopeUnverifiable: 'scope_unverifiable',
} as const;
/**
* computeNextAttemptAt: the next absolute retry timestamp for a failed outbox row. When the server
* supplies a Retry-After (retryAfterMs) it is honored verbatim. Otherwise it is capped exponential
* backoff (DRAFT_SYNC_RETRY_BASE_MS * 2^attemptCount, ceilinged at DRAFT_SYNC_RETRY_MAX_MS) with
* +/-DRAFT_SYNC_RETRY_JITTER proportional jitter. The result is never negative.
*/
export function computeNextAttemptAt(attemptCount: number, now: number, retryAfterMs?: number): number {
if (retryAfterMs != null) {
return Math.max(0, now + retryAfterMs);
}
const base = Math.min(DRAFT_SYNC_RETRY_BASE_MS * (2 ** attemptCount), DRAFT_SYNC_RETRY_MAX_MS);
const jitter = base * DRAFT_SYNC_RETRY_JITTER * ((Math.random() * 2) - 1);
return Math.max(0, Math.round(now + base + jitter));
}
// isErrorWithHeaders: narrow guard for a ClientError-shaped object carrying response headers.
const isErrorWithHeaders = (error: unknown): error is {headers: Record<string, string>} => {
return typeof error === 'object' && error !== null && 'headers' in error &&
typeof (error as {headers: unknown}).headers === 'object' && (error as {headers: unknown}).headers !== null;
};
// readRetryAfterMs: parse a Retry-After (seconds) response header into milliseconds, or undefined.
const readRetryAfterMs = (error: unknown): number | undefined => {
if (!isErrorWithHeaders(error)) {
return undefined;
}
const raw = error.headers['Retry-After'] ?? error.headers['retry-after'];
if (!raw) {
return undefined;
}
const seconds = parseInt(raw, 10);
if (Number.isNaN(seconds) || seconds < 0) {
return undefined;
}
return seconds * 1000;
};
/**
* classifyOutboxError: map a caught worker error to a WorkerOutcome and persist the matching outbox
* transition (guarded by generation so a newer local edit during the request is never clobbered).
* See the error-classification table in the drafts-sync spec. Never logs message/props/file/ids.
*/
const classifyOutboxError = async (
serverUrl: string,
database: Database,
channelId: string,
rootId: string,
generation: number,
error: unknown,
logPrefix: string,
isDelete: boolean,
): Promise<WorkerOutcome> => {
const status = isErrorWithStatusCode(error) ? error.status_code : undefined;
logDebug(`${logPrefix}: request failed`, `status=${status ?? 'none'}`, getFullErrorMessage(error));
// Persist an outbox transition only while the generation still matches the one we sent.
const applyOutbox = (mutate: (o: DraftOutboxModel) => void) => mutateDraftAndOutbox(database, channelId, rootId, ({outbox}) => {
if (!outbox || outbox.generation !== generation) {
return [];
}
return [outbox.prepareUpdate(mutate)];
});
switch (status) {
case 401:
// The forced-logout flow owns recovery; leave the row pending for after re-auth.
forceLogoutIfNecessary(serverUrl, error);
return {outcome: 'retry'};
case 403:
await applyOutbox((o) => {
o.status = DraftOutboxStatus.Blocked;
o.lastErrorCode = OUTBOX_ERROR.Forbidden;
});
return {outcome: 'blocked'};
case 400:
await applyOutbox((o) => {
o.status = DraftOutboxStatus.Blocked;
o.lastErrorCode = OUTBOX_ERROR.Invalid;
});
return {outcome: 'blocked'};
case 429: {
const retryAfterMs = readRetryAfterMs(error);
await applyOutbox((o) => {
o.attemptCount += 1;
o.nextAttemptAt = computeNextAttemptAt(o.attemptCount, Date.now(), retryAfterMs);
});
return {outcome: 'retry', retryAfterMs};
}
case 501:
await applyOutbox((o) => {
o.status = DraftOutboxStatus.Blocked;
o.lastErrorCode = OUTBOX_ERROR.SyncDisabled;
});
return {outcome: 'suspend'};
case 404:
if (isDelete) {
// The supported delete route returns 200 even for an already-absent row, so a 404 means
// the route/capability is unavailable: block and suspend this session.
await applyOutbox((o) => {
o.status = DraftOutboxStatus.Blocked;
o.lastErrorCode = OUTBOX_ERROR.UnsupportedRoute;
});
return {outcome: 'suspend'};
}
// A 404 on upsert is not otherwise expected; treat as transient with capped backoff.
await applyOutbox((o) => {
o.attemptCount += 1;
o.nextAttemptAt = computeNextAttemptAt(o.attemptCount, Date.now());
});
return {outcome: 'retry'};
default:
// 5xx / network / timeout / malformed response: keep pending with capped backoff.
await applyOutbox((o) => {
o.attemptCount += 1;
o.nextAttemptAt = computeNextAttemptAt(o.attemptCount, Date.now());
});
return {outcome: 'retry'};
}
};
/**
* processOutboxUpsert: drain a single Pending upsert outbox row to the server. It is DB-focused: it
* captures the row generation, POSTs outside any writer, then re-checks the generation inside the ack
* writer so a newer local edit during the POST is never clobbered. Epoch checks are the caller's job.
*/
export async function processOutboxUpsert(serverUrl: string, channelId: string, rootId: string, opts?: OutboxWorkerOpts): Promise<WorkerOutcome> {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {outcome: 'done'};
}
const outbox = await getDraftOutbox(database, channelId, rootId);
if (!outbox || outbox.operation !== DraftOutboxOperation.Upsert || outbox.status !== DraftOutboxStatus.Pending) {
return {outcome: 'done'};
}
const draft = await getDraft(database, channelId, rootId);
// A pending upsert with no local Draft is unresolvable: never fabricate content or POST.
if (!draft) {
logDebug('processOutboxUpsert: no local draft for pending upsert');
await mutateDraftAndOutbox(database, channelId, rootId, ({outbox: o}) => {
if (!o || o.operation !== DraftOutboxOperation.Upsert || o.status !== DraftOutboxStatus.Pending) {
return [];
}
return [o.prepareUpdate((x) => {
x.status = DraftOutboxStatus.Blocked;
x.lastErrorCode = OUTBOX_ERROR.MissingLocalDraft;
})];
});
return {outcome: 'blocked'};
}
const request = buildDraftUpsertRequest({
channelId: draft.channelId,
rootId: draft.rootId,
message: draft.message,
type: draft.type as PostTypesUserCreatable | null,
props: draft.props,
fileIds: draft.fileIds ?? [],
metadata: draft.metadata,
});
if (!request) {
// Empty message: the server treats an empty upsert as a delete. Convert to an explicit delete
// when there is remote/attachment state to clear; otherwise park as unsyncable-empty.
const hasFiles = draft.files.length > 0;
const wasDispatched = (draft.serverUpdateAt ?? 0) > 0;
if (hasFiles || wasDispatched) {
const deletedFingerprint = draftContentFingerprint({
message: draft.message,
type: draft.type,
props: draft.props,
fileIds: draft.fileIds ?? [],
priority: draft.metadata?.priority,
});
await mutateDraftAndOutbox(database, channelId, rootId, ({database: db, outbox: o}) => {
if (!o) {
return [];
}
return prepareDraftOutbox(db, channelId, rootId, o.teamId, o, {type: 'delete', keepLocal: hasFiles, deletedFingerprint});
});
return {outcome: 'converted'};
}
await mutateDraftAndOutbox(database, channelId, rootId, ({database: db, outbox: o}) => {
if (!o) {
return [];
}
return prepareDraftOutbox(db, channelId, rootId, o.teamId, o, {type: 'park'});
});
return {outcome: 'blocked'};
}
const generation = outbox.generation;
const connectionId = websocketManager.getClient(serverUrl)?.getConnectionId();
// Read the per-key observation ordinal right before dispatch so the ack can detect a reconcile GET
// that observed newer content for this key while the POST was in flight (see OutboxWorkerOpts).
const observation = opts?.captureObservation?.() ?? 0;
let draftApi: DraftApi;
try {
const client = NetworkManager.getClient(serverUrl);
draftApi = await client.upsertDraft(request, connectionId);
} catch (error) {
// Late failure against a destroyed/recreated DB or a torn-down server: write nothing.
if (shouldAbortPostHttpWrite(serverUrl, database, opts)) {
return {outcome: 'done'};
}
return classifyOutboxError(serverUrl, database, channelId, rootId, generation, error, 'processOutboxUpsert', false);
}
// Guard the success ack write: a logout/wipe during the POST must not write to a stale DB.
if (shouldAbortPostHttpWrite(serverUrl, database, opts)) {
return {outcome: 'done'};
}
let outcome: WorkerOutcome = {outcome: 'done'};
await mutateDraftAndOutbox(database, channelId, rootId, ({database: db, draft: d, outbox: o}) => {
// A newer local mutation happened during the POST (generation bumped) OR a reconcile GET
// observed newer content for this key while the POST was in flight (observation ordinal
// changed): do not clear the outbox or overwrite newer content; only advance the observed
// server_update_at. A re-POST will capture the new generation/ordinal.
if (!o || o.generation !== generation || opts?.observationChanged?.(observation)) {
outcome = {outcome: 'retry'};
if (d) {
const observedUpdateAt = Math.max(d.serverUpdateAt ?? 0, draftApi.update_at);
return [d.prepareUpdate((x) => {
x.serverUpdateAt = observedUpdateAt;
})];
}
return [];
}
const models: Model[] = [];
if (d) {
models.push(d.prepareUpdate((x) => {
x.serverUpdateAt = draftApi.update_at;
}));
}
// An in-progress upload (a file with no server id) keeps the row alive as waiting_for_upload so
// the eventual upload-complete edit re-sends; otherwise the row is fully acked and removed. The
// waitingForUpload INTENT would no-op on a still-Pending row, so transition the status directly.
const hasPendingUpload = (d?.files ?? []).some((f) => !f.id);
if (hasPendingUpload) {
models.push(o.prepareUpdate((x) => {
x.status = DraftOutboxStatus.WaitingForUpload;
x.attemptCount = 0;
x.nextAttemptAt = 0;
x.lastErrorCode = null;
}));
} else {
models.push(...prepareDraftOutbox(db, channelId, rootId, o.teamId, o, {type: 'remove'}));
}
outcome = {outcome: 'done'};
return models;
});
return outcome;
}
/**
* processOutboxDelete: drain a single Pending delete tombstone to the server. A 200 is NOT proof of
* absence (replica lag), so success hands off to confirming_delete; the reconciliation absence pass
* clears the tombstone after confirmed GET absences. A 404 means the delete route is unsupported.
*/
export async function processOutboxDelete(serverUrl: string, channelId: string, rootId: string, opts?: OutboxWorkerOpts): Promise<WorkerOutcome> {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {outcome: 'done'};
}
const outbox = await getDraftOutbox(database, channelId, rootId);
if (!outbox || outbox.operation !== DraftOutboxOperation.Delete || outbox.status !== DraftOutboxStatus.Pending) {
return {outcome: 'done'};
}
const generation = outbox.generation;
// Scope authority: a non-DM/GM channel whose membership row is gone cannot be verified; retain the
// tombstone and block rather than risk deleting a draft we no longer have authority over.
const channel = await getChannelById(database, channelId);
const isDmGm = channel?.type === General.DM_CHANNEL || channel?.type === General.GM_CHANNEL;
if (!isDmGm) {
const membership = await getMyChannel(database, channelId);
if (!membership) {
await mutateDraftAndOutbox(database, channelId, rootId, ({outbox: o}) => {
if (!o || o.generation !== generation) {
return [];
}
return [o.prepareUpdate((x) => {
x.status = DraftOutboxStatus.Blocked;
x.lastErrorCode = OUTBOX_ERROR.ScopeUnverifiable;
})];
});
return {outcome: 'blocked'};
}
}
const connectionId = websocketManager.getClient(serverUrl)?.getConnectionId();
try {
const client = NetworkManager.getClient(serverUrl);
await client.deleteDraft(channelId, rootId, connectionId);
} catch (error) {
// Late failure against a destroyed/recreated DB or a torn-down server: write nothing.
if (shouldAbortPostHttpWrite(serverUrl, database, opts)) {
return {outcome: 'done'};
}
return classifyOutboxError(serverUrl, database, channelId, rootId, generation, error, 'processOutboxDelete', true);
}
// Guard the confirming-delete transition: a logout/wipe during the DELETE must not write to a stale DB.
if (shouldAbortPostHttpWrite(serverUrl, database, opts)) {
return {outcome: 'done'};
}
// Success: hand off to the absence pass. A genuine edit that flipped the row to a new operation (or
// bumped the generation) is retained untouched for the drain to pick up next.
await mutateDraftAndOutbox(database, channelId, rootId, ({outbox: o}) => {
if (!o || o.operation !== DraftOutboxOperation.Delete || o.generation !== generation) {
return [];
}
return [o.prepareUpdate((x) => {
x.status = DraftOutboxStatus.ConfirmingDelete;
})];
});
return {outcome: 'done'};
}