mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-07 11:20:12 +00:00
Addresses 5 validated review findings on the Phase 5 trigger wiring: - #3 Reconnect honored a stale cached capability: doReconnect now awaits handleCapabilityChange (re-reading the freshly-committed sync_drafts preference) before requestReconcile, so a persisted sync_drafts=false is respected on a fresh login. - #4 Disable did not fence in-flight work: handleCapabilityChange's disable branch now bumps the lifecycle epoch (like invalidate) and resets the reconcile single-flight, so a GET/POST/DELETE crossing the disabled interval cannot apply results or set a baseline after re-enable. - #5 Team reconcile ran before the switch committed: moved requestReconcile in handleTeamChange to after the new team is persisted (both the tablet fast-path and the normal path), so a concurrent DB-reading trigger can't coalesce the baseline back onto the previous team. - #6a Reliable reconnect never reconciled: WebsocketManager.onReliableReconnect now calls DraftSyncManager.onReliableReconnect, which GET-reconciles the current team so silent (event-less) server-side deletions are caught. - #7 Overflow could permanently stall event-triggered reconcile: enqueue now fires the reconcile trigger even on buffer overflow (dropping only the push), so a full buffer left by a transient current-team lookup failure cannot wedge reconciliation after the database recovers. Pushed back (not changed): #1 (fence absent keys — would resurrect deleted drafts; belongs to Phase 6 deleted_fingerprint) and #2 (drain during pending GET is local-wins-safe, absence is 2-observation guarded). Deferred: #6b suspend/resume, #8a Global-Drafts stale-on-open, #8b reconciliation deadline. tsc + eslint clean; new manager tests for #4/#6a/#7 and a reconnect capability/ordering assertion for #3. All affected suites green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
918 lines
44 KiB
TypeScript
918 lines
44 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {Q, type Database} from '@nozbe/watermelondb';
|
|
|
|
import {computeNextAttemptAt, confirmDeleteTombstone, deleteAbsentCleanDraft, getReconcilableKeys, processOutboxDelete, processOutboxUpsert, reconcileTeamDrafts, type OutboxWorkerOpts, type ReconcileKey, type WorkerOutcome} from '@actions/remote/draft';
|
|
import {General} from '@constants';
|
|
import {MM_TABLES} from '@constants/database';
|
|
import {DRAFT_ABSENCE_CONFIRMATION_DELAY_MS, DRAFT_SYNC_RETRY_BASE_MS, DRAFT_SYNC_RETRY_JITTER, DRAFT_SYNC_RETRY_MAX_MS, DraftOutboxOperation, DraftOutboxStatus, MAX_DRAFT_SYNC_EVENT_BUFFER} from '@constants/draft';
|
|
import DatabaseManager from '@database/manager';
|
|
import {getChannelById, getMyChannel} from '@queries/servers/channel';
|
|
import {buildDraftOutboxId, getIsDraftSyncEnabled, mutateDraftAndOutbox} from '@queries/servers/drafts';
|
|
import {getCurrentTeamId} from '@queries/servers/system';
|
|
import {logDebug} from '@utils/log';
|
|
|
|
import type DraftOutboxModel from '@typings/database/models/servers/draft_outbox';
|
|
import type {NormalizedDraft} from '@utils/draft/sync';
|
|
|
|
// AbsenceCandidate: an in-memory record that a draft key was observed absent from a successful GET
|
|
// snapshot. Deletion is only confirmed on a SECOND same-scope absence >= DRAFT_ABSENCE_CONFIRMATION_
|
|
// DELAY_MS later. Losing these on restart is safe — it merely delays (never mis-triggers) deletion.
|
|
type AbsenceCandidate = {firstAbsentAt: number; teamId: string};
|
|
|
|
/**
|
|
* isAbsenceEligible: whether an in-scope key that is ABSENT from the snapshot may (eventually) be
|
|
* removed. A non-authoritative key (membership lost) is never eligible. A plain Draft is eligible
|
|
* only when it is clean and server-backed (serverUpdateAt > 0, no outbox). A delete tombstone is
|
|
* eligible only while it is a Delete in a Pending/ConfirmingDelete status.
|
|
*/
|
|
const isAbsenceEligible = (entry: ReconcileKey): boolean => {
|
|
if (!entry.authoritative) {
|
|
return false;
|
|
}
|
|
|
|
if (entry.kind === 'draft') {
|
|
return entry.serverUpdateAt > 0 && !entry.hasOutbox;
|
|
}
|
|
|
|
return entry.outboxOperation === DraftOutboxOperation.Delete &&
|
|
(entry.outboxStatus === DraftOutboxStatus.Pending || entry.outboxStatus === DraftOutboxStatus.ConfirmingDelete);
|
|
};
|
|
|
|
const {DRAFT_OUTBOX} = MM_TABLES.SERVER;
|
|
|
|
/**
|
|
* DraftSyncManager: the per-server coordinator for synchronized drafts.
|
|
*
|
|
* It owns per-server lifecycle state, a lifecycle epoch that rejects stale async continuations, a
|
|
* bounded inbound WebSocket event buffer, the baseline gate, in-memory absence candidates, and two
|
|
* timers scheduled by scheduleWork: a DRAIN timer (POST/DELETE the outbox, only with a baseline) and
|
|
* a RECONCILE timer (GET the team snapshot to establish/refresh the baseline or run a delayed
|
|
* absence/confirmation observation, only with a known team). Neither timer can form a zero-delay
|
|
* loop: the drain timer requires due Pending work under a baseline, and the reconcile timer always
|
|
* uses a real backoff/confirmation delay.
|
|
*
|
|
* Per-server state is keyed by serverUrl. A server entry is "present" once initialize() (or any
|
|
* scheduling path) has created it. A missing entry is treated as invalidated: no new work is
|
|
* scheduled and captured epochs are considered stale.
|
|
*/
|
|
class DraftSyncManagerSingleton {
|
|
// lifecycleEpoch: monotonically increasing per server; bumped by invalidate(). A captured
|
|
// epoch that no longer matches (or whose server entry is gone) marks a stale continuation.
|
|
private lifecycleEpoch: Record<string, number> = {};
|
|
|
|
// retryTimers: the OUTBOX DRAIN timer (at most one per server). Armed only when a baseline exists
|
|
// and there are due Pending rows in scope; its fire calls drainOutbox. Never armed without a
|
|
// baseline (draining without a baseline would no-op), so it can never form a zero-delay loop.
|
|
private retryTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
|
|
|
// reconcileTimers: the RECONCILE (GET) timer (at most one per server). Armed with a real delay
|
|
// (never 0) to (a) establish a baseline before draining, (b) retry a failed GET with backoff, or
|
|
// (c) run the delayed second observation for confirming-delete tombstones / absence candidates.
|
|
// Requires a known team (baseline.teamId or the last requested team); without one, nothing is armed.
|
|
private reconcileTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
|
|
|
// reconcileAttempt: consecutive failed-GET count per server, driving the reconcile backoff. Reset
|
|
// to 0 on a successful reconcile.
|
|
private reconcileAttempt: Record<string, number> = {};
|
|
|
|
// eventBuffers: inbound WebSocket events awaiting Phase 4 draining, bounded by
|
|
// MAX_DRAFT_SYNC_EVENT_BUFFER. Overflowing events are dropped (oldest kept) with a debug log.
|
|
private eventBuffers: Record<string, WebSocketMessage[]> = {};
|
|
|
|
// enabled: cached draft-sync capability per server (config AND user preference). false when
|
|
// disabled OR invalidated so no work is scheduled.
|
|
private enabled: Record<string, boolean> = {};
|
|
|
|
// lastReconcile: last requested reconciliation intent per server (Phase 3 records only).
|
|
private lastReconcile: Record<string, {teamId: string; reason: string}> = {};
|
|
|
|
// baseline: the last successful reconciliation snapshot marker per server (teamId + timestamp).
|
|
// Set only after a reconcile whose epoch stayed valid succeeds. A later sub-step uses it to gate
|
|
// absence-based decisions; this sub-step only records it.
|
|
private baseline: Record<string, {teamId: string; at: number}> = {};
|
|
|
|
// reconcileInFlight: true while a reconcile await is outstanding for the server (single-flight).
|
|
private reconcileInFlight: Record<string, boolean> = {};
|
|
|
|
// reconcilePending: a coalesced reconcile request that arrived while one was in flight. Drained
|
|
// (run once) after the in-flight reconcile settles.
|
|
private reconcilePending: Record<string, {teamId: string} | undefined> = {};
|
|
|
|
// absenceCandidates: per-server map (draftKey -> first-absence observation) for the two-observation
|
|
// replica-lag guard. A key is only deleted after being observed absent in TWO same-scope reconciles
|
|
// separated by >= DRAFT_ABSENCE_CONFIRMATION_DELAY_MS. Cleared on invalidate; safe to lose.
|
|
private absenceCandidates: Record<string, Map<string, AbsenceCandidate>> = {};
|
|
|
|
// inFlightKeys: per-server set of outbox keys (buildDraftOutboxId) currently being drained by a
|
|
// worker. Guarantees per-key serialization across overlapping drains (a wake firing while a timer
|
|
// drain runs) so the same row is never POSTed/DELETEd twice concurrently. Cleared on invalidate.
|
|
private inFlightKeys: Record<string, Set<string>> = {};
|
|
|
|
// observationOrdinals: per-server map (draftKey -> monotonically increasing counter). A reconcile
|
|
// GET that observes newer content for a key whose POST is in flight bumps its ordinal; the worker
|
|
// captured the ordinal before dispatch and, on ack, refuses to clobber the newer content when it
|
|
// changed. Init in ensureServer, cleared on invalidate; safe to lose (a re-POST re-captures it).
|
|
private observationOrdinals: Record<string, Map<string, number>> = {};
|
|
|
|
/**
|
|
* initialize: idempotent per-server setup. Reads the draft-sync capability into `enabled` and,
|
|
* when enabled, reconstructs the retry timer from the outbox. Safe to call when the server
|
|
* database is absent (no-op after ensuring the entry exists). Never throws.
|
|
*/
|
|
public initialize = async (serverUrl: string): Promise<void> => {
|
|
this.ensureServer(serverUrl);
|
|
|
|
const database = this.getDatabase(serverUrl);
|
|
if (!database) {
|
|
logDebug('DraftSyncManager.initialize: database absent', serverUrl);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.enabled[serverUrl] = await getIsDraftSyncEnabled(database);
|
|
} catch (error) {
|
|
logDebug('DraftSyncManager.initialize: capability read failed', serverUrl);
|
|
return;
|
|
}
|
|
|
|
// Interrupted-upload recovery: any WaitingForUpload row survived a process restart with no
|
|
// live upload behind it, so it can never self-complete. Conservatively transition every such
|
|
// row to BlockedUpload('upload_interrupted'); a later genuine edit / upload retry re-activates
|
|
// it. Phase: refine with DraftEditPostUploadManager to only transition rows with no in-progress
|
|
// upload once that manager is reachable from here.
|
|
await this.recoverInterruptedUploads(database);
|
|
|
|
// Do NOT arm a drain timer here: there is no baseline yet (draining needs one) and no known
|
|
// team to reconcile until a trigger (requestReconcile) supplies one. scheduleWork therefore
|
|
// arms nothing on a cold start with no team/baseline, which is what prevents a busy loop.
|
|
if (this.enabled[serverUrl]) {
|
|
await this.scheduleWork(serverUrl);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* recoverInterruptedUploads: mark every WaitingForUpload outbox row as BlockedUpload with
|
|
* lastErrorCode 'upload_interrupted'. Called once on initialize because a WaitingForUpload row that
|
|
* outlived the process has no live upload behind it. Never throws.
|
|
*/
|
|
private recoverInterruptedUploads = async (database: Database): Promise<void> => {
|
|
try {
|
|
const rows = await database.collections.get<DraftOutboxModel>(DRAFT_OUTBOX).query(
|
|
Q.where('status', DraftOutboxStatus.WaitingForUpload),
|
|
).fetch();
|
|
|
|
if (!rows.length) {
|
|
return;
|
|
}
|
|
|
|
const updates = rows.map((row) => row.prepareUpdate((o) => {
|
|
o.status = DraftOutboxStatus.BlockedUpload;
|
|
o.lastErrorCode = 'upload_interrupted';
|
|
}));
|
|
|
|
await database.write(async (writer) => {
|
|
await writer.batch(...updates);
|
|
}, 'recoverInterruptedUploads');
|
|
} catch (error) {
|
|
logDebug('DraftSyncManager.recoverInterruptedUploads: query/write failed');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* wake: called after a committed local mutation. Reconstructs/reschedules the retry timer so
|
|
* newly-enqueued outbox work gets a heartbeat. Never sends.
|
|
*/
|
|
public wake = (serverUrl: string): void => {
|
|
if (!this.isActive(serverUrl)) {
|
|
return;
|
|
}
|
|
|
|
// Fire-and-forget: draining is async (DB + HTTP) but wake() is synchronous by contract. With a
|
|
// baseline established, drain immediately (drainOutbox reschedules via scheduleWork when it
|
|
// ends); without one, let scheduleWork arm a reconcile (only if a team is known) so the work
|
|
// drains once a baseline appears.
|
|
if (this.baseline[serverUrl]) {
|
|
this.drainOutbox(serverUrl);
|
|
} else {
|
|
this.scheduleWork(serverUrl);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* requestReconcile: records the request and fires the additive baseline reconciliation
|
|
* (fire-and-forget; synchronous by contract). Draining/absence-detection stay in later sub-steps.
|
|
*/
|
|
public requestReconcile = (serverUrl: string, teamId: string, reason: string): void => {
|
|
if (!this.isActive(serverUrl)) {
|
|
return;
|
|
}
|
|
|
|
if (!teamId) {
|
|
// The draft GET is per-team (/users/me/teams/{team_id}/drafts); an empty team id cannot be
|
|
// reconciled. Skip rather than hit an invalid route (e.g. a trigger fired before a team is set).
|
|
logDebug('DraftSyncManager.requestReconcile: no team id, skipping', serverUrl, reason);
|
|
return;
|
|
}
|
|
|
|
this.lastReconcile[serverUrl] = {teamId, reason};
|
|
logDebug('DraftSyncManager.requestReconcile', serverUrl, reason);
|
|
|
|
// Additive baseline reconciliation (GET drafts for the team, apply the snapshot), then absence
|
|
// detection and outbox draining (all downstream of a successful GET inside reconcile()).
|
|
this.reconcile(serverUrl, teamId);
|
|
};
|
|
|
|
/**
|
|
* reconcile: single-flight, epoch-guarded additive baseline reconciliation for a team. It applies
|
|
* the server snapshot via reconcileTeamDrafts (which never deletes for absence) and, on success,
|
|
* records the baseline. It never drains/POSTs/DELETEs. Concurrent requests coalesce: the latest
|
|
* teamId is remembered and run once after the in-flight pass settles.
|
|
*/
|
|
private reconcile = async (serverUrl: string, teamId: string): Promise<void> => {
|
|
if (!this.isActive(serverUrl)) {
|
|
return;
|
|
}
|
|
|
|
if (this.reconcileInFlight[serverUrl]) {
|
|
// Coalesce: remember the latest request and let the in-flight pass drain it when it ends.
|
|
this.reconcilePending[serverUrl] = {teamId};
|
|
return;
|
|
}
|
|
|
|
this.reconcileInFlight[serverUrl] = true;
|
|
const captured = this.captureEpoch(serverUrl);
|
|
|
|
let res: Awaited<ReturnType<typeof reconcileTeamDrafts>>;
|
|
try {
|
|
res = await reconcileTeamDrafts(serverUrl, teamId, {
|
|
|
|
// Fail closed if the server was torn down while the GET was in flight: no snapshot write.
|
|
shouldAbort: () => this.isEpochStale(serverUrl, captured) || !this.isActive(serverUrl),
|
|
|
|
// Post-dispatch observation fence: bump the ordinal for every observed key whose POST is
|
|
// in flight, SYNCHRONOUSLY as the GET resolves (before the apply loop). A concurrent ack
|
|
// then sees the change and refuses to clear the outbox / clobber this GET's observation.
|
|
onSnapshot: (keys) => {
|
|
const inFlight = this.inFlightKeys[serverUrl];
|
|
if (!inFlight) {
|
|
return;
|
|
}
|
|
for (const {channelId, rootId} of keys) {
|
|
if (inFlight.has(buildDraftOutboxId(channelId, rootId))) {
|
|
this.bumpObservation(serverUrl, channelId, rootId);
|
|
}
|
|
}
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res = {error};
|
|
}
|
|
|
|
// A concurrent invalidate/disable during the await discards this continuation entirely.
|
|
if (this.isEpochStale(serverUrl, captured) || !this.isActive(serverUrl)) {
|
|
this.reconcileInFlight[serverUrl] = false;
|
|
return;
|
|
}
|
|
|
|
if (res.error) {
|
|
// Failure: do NOT set a baseline and run NO absence pass (without a snapshot nothing may be
|
|
// deleted). Increment the backoff count; scheduleWork arms a reconcile-GET retry with a real
|
|
// delay (never 0) so a persistently failing GET can never form a busy loop.
|
|
logDebug('DraftSyncManager.reconcile: reconciliation failed', serverUrl);
|
|
this.reconcileAttempt[serverUrl] = (this.reconcileAttempt[serverUrl] ?? 0) + 1;
|
|
} else {
|
|
this.reconcileAttempt[serverUrl] = 0;
|
|
this.baseline[serverUrl] = {teamId, at: Date.now()};
|
|
|
|
// (The observation fence for in-flight POSTs is bumped synchronously via onSnapshot above,
|
|
// the moment the GET resolves — not here, which would be after the whole apply loop.)
|
|
|
|
// Absence pass: a successful snapshot lets us quarantine/confirm keys that are absent
|
|
// from it. Epoch-guarded internally; it never POSTs/DELETEs over the network.
|
|
await this.runAbsencePass(serverUrl, teamId, res.drafts ?? [], captured);
|
|
|
|
// A fresh baseline means queued outbox work can now drain. drainOutbox re-checks epoch and
|
|
// calls scheduleWork when it ends (which re-arms the delayed confirmation reconcile if any
|
|
// confirming-delete/absence candidates remain).
|
|
if (!this.isEpochStale(serverUrl, captured) && this.isActive(serverUrl)) {
|
|
await this.drainOutbox(serverUrl);
|
|
}
|
|
|
|
// Missing dependencies: the snapshot referenced channels not yet hydrated, so those keys
|
|
// were skipped. Record a soft attempt so scheduleWork arms a reconcile-GET retry (a real
|
|
// backoff delay, never zero) to re-fetch once dependencies may have hydrated. A subsequent
|
|
// fully-applied reconcile resets the attempt to 0.
|
|
if (res.missingDeps && !this.isEpochStale(serverUrl, captured) && this.isActive(serverUrl)) {
|
|
this.reconcileAttempt[serverUrl] = (this.reconcileAttempt[serverUrl] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
this.reconcileInFlight[serverUrl] = false;
|
|
|
|
// Re-evaluate timers (reconcile backoff on failure; delayed confirmation on success).
|
|
await this.scheduleWork(serverUrl);
|
|
|
|
const pending = this.reconcilePending[serverUrl];
|
|
if (pending) {
|
|
this.reconcilePending[serverUrl] = undefined;
|
|
this.reconcile(serverUrl, pending.teamId);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* runAbsencePass: SAFE absence-based local convergence after a successful snapshot. Every in-scope
|
|
* key that has a Draft and/or a delete tombstone is classified: keys present in the snapshot clear
|
|
* their candidate; ineligible keys (legacy/local-only, pending intent, membership lost) are
|
|
* preserved and clear their candidate; eligible-but-absent keys are QUARANTINED on first absence
|
|
* and only CONFIRMED (deleted / tombstone-resolved) on a second same-scope absence >= DELAY later.
|
|
* It performs NO network activity and re-checks epoch staleness before each mutation.
|
|
*/
|
|
private runAbsencePass = async (serverUrl: string, teamId: string, drafts: NormalizedDraft[], captured: number): Promise<void> => {
|
|
const database = this.getDatabase(serverUrl);
|
|
const candidates = this.absenceCandidates[serverUrl];
|
|
if (!database || !candidates) {
|
|
return;
|
|
}
|
|
|
|
let universe: ReconcileKey[];
|
|
try {
|
|
universe = await getReconcilableKeys(database, teamId);
|
|
} catch (error) {
|
|
logDebug('DraftSyncManager.runAbsencePass: universe query failed', serverUrl);
|
|
return;
|
|
}
|
|
|
|
// A concurrent invalidate/disable during the await discards this pass entirely.
|
|
if (this.isEpochStale(serverUrl, captured) || !this.isActive(serverUrl)) {
|
|
return;
|
|
}
|
|
|
|
const present = new Set(drafts.map((d) => buildDraftOutboxId(d.channelId, d.rootId)));
|
|
const universeKeys = new Set(universe.map((e) => buildDraftOutboxId(e.channelId, e.rootId)));
|
|
const now = Date.now();
|
|
|
|
// Drop candidates for keys that vanished from the universe by other means (deleted locally, etc.).
|
|
for (const key of candidates.keys()) {
|
|
if (!universeKeys.has(key)) {
|
|
candidates.delete(key);
|
|
}
|
|
}
|
|
|
|
const confirmed: ReconcileKey[] = [];
|
|
for (const entry of universe) {
|
|
const key = buildDraftOutboxId(entry.channelId, entry.rootId);
|
|
|
|
// Reappeared or ineligible (preserve): clear any absence candidate.
|
|
if (present.has(key) || !isAbsenceEligible(entry)) {
|
|
candidates.delete(key);
|
|
continue;
|
|
}
|
|
|
|
const candidate = candidates.get(key);
|
|
if (!candidate) {
|
|
// First absence: quarantine, delete nothing yet.
|
|
candidates.set(key, {firstAbsentAt: now, teamId});
|
|
} else if (candidate.teamId !== teamId) {
|
|
// Scope changed since the first observation: restart the window under the new scope.
|
|
candidates.set(key, {firstAbsentAt: now, teamId});
|
|
} else if ((now - candidate.firstAbsentAt) >= DRAFT_ABSENCE_CONFIRMATION_DELAY_MS) {
|
|
// Second same-scope absence past the delay: deletion confirmed.
|
|
confirmed.push(entry);
|
|
}
|
|
}
|
|
|
|
for (const entry of confirmed) {
|
|
if (this.isEpochStale(serverUrl, captured) || !this.isActive(serverUrl)) {
|
|
return;
|
|
}
|
|
|
|
if (entry.kind === 'tombstone') {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await confirmDeleteTombstone(serverUrl, entry.channelId, entry.rootId);
|
|
} else {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await deleteAbsentCleanDraft(serverUrl, entry.channelId, entry.rootId);
|
|
}
|
|
candidates.delete(buildDraftOutboxId(entry.channelId, entry.rootId));
|
|
}
|
|
|
|
if (confirmed.length) {
|
|
logDebug('DraftSyncManager.runAbsencePass: confirmed absence deletions', serverUrl, confirmed.length);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* enqueueWebSocketEvent: appends an inbound draft event synchronously, then triggers an
|
|
* authoritative resync. Phase 5 treats WS events (draft_created/draft_deleted) as CHANGE SIGNALS,
|
|
* not as data to apply directly: the server only broadcasts these to the owning user's other
|
|
* sessions, but an empty-message upsert deletes a draft with NO event, so a GET reconciliation is
|
|
* required regardless. The trigger reconciles the current team (whose GET also returns the user's
|
|
* DM/GM drafts); bursts collapse via reconcile()'s single-flight coalescing. The buffered events
|
|
* themselves are reserved for a future direct-apply optimization. Ignored when disabled/invalidated;
|
|
* enforces the buffer cap (dropping the newest with an overflow log) so a flood cannot grow unbounded.
|
|
*
|
|
* The reconcile trigger fires even on overflow: dropping only the push (not the trigger) means a
|
|
* transient current-team lookup failure that left the buffer full cannot permanently stall
|
|
* event-triggered reconciliation once the database recovers.
|
|
*/
|
|
public enqueueWebSocketEvent = (serverUrl: string, event: WebSocketMessage): void => {
|
|
if (!this.isActive(serverUrl)) {
|
|
return;
|
|
}
|
|
|
|
const buffer = this.eventBuffers[serverUrl];
|
|
if (buffer.length >= MAX_DRAFT_SYNC_EVENT_BUFFER) {
|
|
logDebug('DraftSyncManager.enqueueWebSocketEvent: buffer overflow, dropping event', serverUrl, buffer.length);
|
|
} else {
|
|
buffer.push(event);
|
|
}
|
|
|
|
this.reconcileCurrentTeam(serverUrl, 'ws_event', true);
|
|
};
|
|
|
|
/**
|
|
* onForeground: lifecycle entry point invoked by WebsocketManager when the app becomes active (it
|
|
* owns the single AppState listener; the manager adds none). Re-syncs each active server's current
|
|
* team so a snapshot that went stale while backgrounded refreshes — covering the case where the
|
|
* socket stayed open (no reconnect fired). requestReconcile coalesces with any concurrent reconnect.
|
|
*/
|
|
public onForeground = (): void => {
|
|
for (const serverUrl of Object.keys(this.eventBuffers)) {
|
|
if (this.isActive(serverUrl)) {
|
|
this.reconcileCurrentTeam(serverUrl, 'foreground', false);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* onReliableReconnect: lifecycle entry point for a reliable WebSocket reconnect (which replays
|
|
* missed events but does NOT run the full entry/reconnect flow). A GET reconcile is still required
|
|
* because some server-side cleanup deletes drafts with no WebSocket event, so replay alone cannot
|
|
* surface those removals. Re-syncs the given server's current team; a no-op when sync is disabled.
|
|
*/
|
|
public onReliableReconnect = (serverUrl: string): void => {
|
|
if (this.isActive(serverUrl)) {
|
|
this.reconcileCurrentTeam(serverUrl, 'reliable_reconnect', false);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* reconcileCurrentTeam: resolve the current team and request an authoritative reconcile of it
|
|
* (fire-and-forget). Epoch-guarded around the async team lookup. For a WS trigger, clearBuffer drops
|
|
* the buffered signals since the GET supersedes them; requestReconcile guards an empty team and
|
|
* coalesces bursts. Shared by the WS-event trigger and the foreground lifecycle trigger.
|
|
*/
|
|
private reconcileCurrentTeam = async (serverUrl: string, reason: string, clearBuffer: boolean): Promise<void> => {
|
|
const database = this.getDatabase(serverUrl);
|
|
if (!database || !this.isActive(serverUrl)) {
|
|
return;
|
|
}
|
|
|
|
const captured = this.captureEpoch(serverUrl);
|
|
|
|
let teamId: string;
|
|
try {
|
|
teamId = await getCurrentTeamId(database);
|
|
} catch (error) {
|
|
logDebug('DraftSyncManager.reconcileCurrentTeam: current team lookup failed', serverUrl);
|
|
return;
|
|
}
|
|
|
|
if (this.isEpochStale(serverUrl, captured) || !this.isActive(serverUrl)) {
|
|
return;
|
|
}
|
|
|
|
if (clearBuffer) {
|
|
// Signals consumed: the authoritative GET below supersedes the buffered events.
|
|
this.eventBuffers[serverUrl] = [];
|
|
}
|
|
this.requestReconcile(serverUrl, teamId, reason);
|
|
};
|
|
|
|
/**
|
|
* handleCapabilityChange: re-reads the capability. enabled->disabled cancels the retry timer and
|
|
* clears the event buffer WITHOUT deleting Draft/DraftOutbox data. disabled->enabled reconstructs
|
|
* the retry timer and wakes.
|
|
*/
|
|
public handleCapabilityChange = async (serverUrl: string): Promise<void> => {
|
|
this.ensureServer(serverUrl);
|
|
|
|
const database = this.getDatabase(serverUrl);
|
|
if (!database) {
|
|
logDebug('DraftSyncManager.handleCapabilityChange: database absent', serverUrl);
|
|
return;
|
|
}
|
|
|
|
let nowEnabled: boolean;
|
|
try {
|
|
nowEnabled = await getIsDraftSyncEnabled(database);
|
|
} catch (error) {
|
|
logDebug('DraftSyncManager.handleCapabilityChange: capability read failed', serverUrl);
|
|
return;
|
|
}
|
|
|
|
const wasEnabled = this.enabled[serverUrl];
|
|
this.enabled[serverUrl] = nowEnabled;
|
|
|
|
if (wasEnabled && !nowEnabled) {
|
|
// enabled -> disabled: stop scheduling and drop transient state, but keep durable rows.
|
|
// CRITICAL: bump the lifecycle epoch (like invalidate) so any GET/POST/DELETE that was
|
|
// in flight across the disabled interval is rejected on completion and cannot apply results
|
|
// or set a baseline if the server is re-enabled before it returns. Also reset the reconcile
|
|
// single-flight so a re-enable starts a fresh pass instead of coalescing behind a stale one.
|
|
// Clear the baseline and absence candidates so re-enabling requires a fresh successful GET
|
|
// before any drain/POST.
|
|
this.lifecycleEpoch[serverUrl] = (this.lifecycleEpoch[serverUrl] ?? 0) + 1;
|
|
this.clearRetryTimer(serverUrl);
|
|
this.clearReconcileTimer(serverUrl);
|
|
this.eventBuffers[serverUrl] = [];
|
|
delete this.baseline[serverUrl];
|
|
this.absenceCandidates[serverUrl] = new Map();
|
|
this.reconcileAttempt[serverUrl] = 0;
|
|
this.reconcileInFlight[serverUrl] = false;
|
|
this.reconcilePending[serverUrl] = undefined;
|
|
logDebug('DraftSyncManager.handleCapabilityChange: disabled', serverUrl);
|
|
return;
|
|
}
|
|
|
|
if (!wasEnabled && nowEnabled) {
|
|
// disabled -> enabled: require a fresh baseline before any draining. If a team is known,
|
|
// reconcile now (which will drain on success); otherwise scheduleWork waits for a trigger.
|
|
logDebug('DraftSyncManager.handleCapabilityChange: enabled', serverUrl);
|
|
const teamId = this.lastReconcile[serverUrl]?.teamId;
|
|
if (teamId) {
|
|
this.reconcile(serverUrl, teamId);
|
|
} else {
|
|
await this.scheduleWork(serverUrl);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* invalidate: SYNCHRONOUS teardown. Bumps the lifecycle epoch (so any captured epoch becomes
|
|
* stale), cancels and clears the retry timer, clears the event buffer, and marks the server
|
|
* disabled so no new work is scheduled. Does NOT await HTTP (there is none in this phase).
|
|
*/
|
|
public invalidate = (serverUrl: string): void => {
|
|
this.lifecycleEpoch[serverUrl] = (this.lifecycleEpoch[serverUrl] ?? 0) + 1;
|
|
this.clearRetryTimer(serverUrl);
|
|
this.clearReconcileTimer(serverUrl);
|
|
this.eventBuffers[serverUrl] = [];
|
|
this.enabled[serverUrl] = false;
|
|
this.lastReconcile[serverUrl] = {teamId: '', reason: ''};
|
|
delete this.baseline[serverUrl];
|
|
this.reconcileInFlight[serverUrl] = false;
|
|
this.reconcilePending[serverUrl] = undefined;
|
|
this.reconcileAttempt[serverUrl] = 0;
|
|
this.absenceCandidates[serverUrl] = new Map();
|
|
this.inFlightKeys[serverUrl] = new Set();
|
|
this.observationOrdinals[serverUrl] = new Map();
|
|
logDebug('DraftSyncManager.invalidate', serverUrl);
|
|
};
|
|
|
|
// captureEpoch: returns the current lifecycle epoch for a later staleness comparison.
|
|
private captureEpoch = (serverUrl: string): number => {
|
|
return this.lifecycleEpoch[serverUrl] ?? 0;
|
|
};
|
|
|
|
// isEpochStale: true when the captured epoch no longer matches the current epoch or the server
|
|
// entry has been removed. Guards async continuations before they touch the database.
|
|
private isEpochStale = (serverUrl: string, captured: number): boolean => {
|
|
if (!(serverUrl in this.lifecycleEpoch)) {
|
|
return true;
|
|
}
|
|
return this.captureEpoch(serverUrl) !== captured;
|
|
};
|
|
|
|
/**
|
|
* scheduleWork: the single decider for which timers to arm. Called after initialize, wake,
|
|
* reconcile, and drainOutbox. It arms at most two timers, and never a zero-delay busy loop:
|
|
*
|
|
* - DRAIN timer (retryTimers): only when a baseline exists AND there is a due Pending upsert/delete
|
|
* row in scope. Its fire drains those rows. A due row fires once, is consumed (removed or backed
|
|
* off to a future nextAttemptAt), so it cannot re-arm at zero.
|
|
* - RECONCILE timer (reconcileTimers): armed only when a team is known, with a real delay:
|
|
* (a) failed GET -> reconcile backoff (>= base);
|
|
* (b) no baseline but Pending work exists -> base delay, to establish a baseline first;
|
|
* (c) confirming-delete rows or absence candidates -> DELAY, for the second observation GET.
|
|
*
|
|
* With no baseline and no known team (a cold start before any reconcile trigger), NOTHING is armed.
|
|
*/
|
|
private scheduleWork = async (serverUrl: string): Promise<void> => {
|
|
if (!this.isActive(serverUrl)) {
|
|
this.clearRetryTimer(serverUrl);
|
|
this.clearReconcileTimer(serverUrl);
|
|
return;
|
|
}
|
|
|
|
const database = this.getDatabase(serverUrl);
|
|
if (!database) {
|
|
this.clearRetryTimer(serverUrl);
|
|
this.clearReconcileTimer(serverUrl);
|
|
return;
|
|
}
|
|
|
|
const captured = this.captureEpoch(serverUrl);
|
|
|
|
let rows: DraftOutboxModel[];
|
|
try {
|
|
rows = await database.collections.get<DraftOutboxModel>(DRAFT_OUTBOX).query().fetch();
|
|
} catch (error) {
|
|
logDebug('DraftSyncManager.scheduleWork: outbox query failed', serverUrl);
|
|
return;
|
|
}
|
|
|
|
if (this.isEpochStale(serverUrl, captured) || !this.isActive(serverUrl)) {
|
|
return;
|
|
}
|
|
|
|
const baseline = this.baseline[serverUrl];
|
|
const teamId = baseline?.teamId || this.lastReconcile[serverUrl]?.teamId || '';
|
|
const inScope = (row: DraftOutboxModel) => row.teamId === teamId || row.teamId === '';
|
|
const isPendingWork = (row: DraftOutboxModel) => row.status === DraftOutboxStatus.Pending &&
|
|
(row.operation === DraftOutboxOperation.Upsert || row.operation === DraftOutboxOperation.Delete);
|
|
const now = Date.now();
|
|
|
|
// --- Drain timer ---
|
|
this.clearRetryTimer(serverUrl);
|
|
if (baseline) {
|
|
const pendingDue = rows.filter((r) => inScope(r) && isPendingWork(r));
|
|
if (pendingDue.length) {
|
|
const earliest = Math.min(...pendingDue.map((r) => r.nextAttemptAt));
|
|
this.armDrainTimer(serverUrl, Math.max(0, earliest - now), captured);
|
|
}
|
|
}
|
|
|
|
// --- Reconcile timer ---
|
|
this.clearReconcileTimer(serverUrl);
|
|
if (teamId) {
|
|
const attempt = this.reconcileAttempt[serverUrl] ?? 0;
|
|
const hasPendingInScope = rows.some((r) => inScope(r) && isPendingWork(r));
|
|
const hasConfirmingInScope = rows.some((r) => inScope(r) && r.status === DraftOutboxStatus.ConfirmingDelete);
|
|
const hasCandidates = (this.absenceCandidates[serverUrl]?.size ?? 0) > 0;
|
|
|
|
let delay: number | undefined;
|
|
if (attempt > 0) {
|
|
delay = this.reconcileBackoffMs(attempt);
|
|
} else if (!baseline && hasPendingInScope) {
|
|
delay = DRAFT_SYNC_RETRY_BASE_MS;
|
|
} else if (hasConfirmingInScope || hasCandidates) {
|
|
delay = DRAFT_ABSENCE_CONFIRMATION_DELAY_MS;
|
|
}
|
|
|
|
if (delay !== undefined) {
|
|
this.armReconcileTimer(serverUrl, teamId, delay, captured);
|
|
}
|
|
}
|
|
};
|
|
|
|
// armDrainTimer: schedule ONE drain timer; the fire re-checks epoch then drains.
|
|
private armDrainTimer = (serverUrl: string, delay: number, captured: number): void => {
|
|
this.retryTimers[serverUrl] = setTimeout(() => {
|
|
if (this.isEpochStale(serverUrl, captured)) {
|
|
return;
|
|
}
|
|
this.drainOutbox(serverUrl);
|
|
}, delay);
|
|
};
|
|
|
|
// armReconcileTimer: schedule ONE reconcile-GET timer; the fire re-checks epoch then reconciles.
|
|
private armReconcileTimer = (serverUrl: string, teamId: string, delay: number, captured: number): void => {
|
|
this.reconcileTimers[serverUrl] = setTimeout(() => {
|
|
if (this.isEpochStale(serverUrl, captured)) {
|
|
return;
|
|
}
|
|
this.reconcile(serverUrl, teamId);
|
|
}, delay);
|
|
};
|
|
|
|
// reconcileBackoffMs: capped exponential backoff (attempt 1 -> base) with +/- jitter, for retrying
|
|
// a failed reconciliation GET.
|
|
private reconcileBackoffMs = (attempt: number): number => {
|
|
const capped = Math.min(DRAFT_SYNC_RETRY_BASE_MS * (2 ** (attempt - 1)), DRAFT_SYNC_RETRY_MAX_MS);
|
|
const jitter = 1 + ((Math.random() * 2 * DRAFT_SYNC_RETRY_JITTER) - DRAFT_SYNC_RETRY_JITTER);
|
|
return Math.max(0, Math.round(capped * jitter));
|
|
};
|
|
|
|
/**
|
|
* drainOutbox: the write path. Sends eligible Pending outbox rows to the server via the per-key
|
|
* workers, with per-key serialization (inFlightKeys), epoch guarding around each worker, and a
|
|
* heartbeat reschedule when it ends. It performs NO network work until a baseline reconciliation
|
|
* exists for the scope (the exit criterion) — without one it only (re)arms the heartbeat.
|
|
*/
|
|
private drainOutbox = async (serverUrl: string): Promise<void> => {
|
|
if (!this.isActive(serverUrl)) {
|
|
return;
|
|
}
|
|
|
|
const baseline = this.baseline[serverUrl];
|
|
if (!baseline) {
|
|
// NO draining without a baseline. Defer to scheduleWork, which arms a reconcile-GET (only
|
|
// if a team is known) to establish one; it never arms a zero-delay drain loop.
|
|
await this.scheduleWork(serverUrl);
|
|
return;
|
|
}
|
|
|
|
const database = this.getDatabase(serverUrl);
|
|
const inFlight = this.inFlightKeys[serverUrl];
|
|
if (!database || !inFlight) {
|
|
return;
|
|
}
|
|
|
|
let rows: DraftOutboxModel[];
|
|
try {
|
|
rows = await database.collections.get<DraftOutboxModel>(DRAFT_OUTBOX).query(
|
|
Q.where('status', DraftOutboxStatus.Pending),
|
|
Q.where('operation', Q.oneOf([DraftOutboxOperation.Upsert, DraftOutboxOperation.Delete])),
|
|
Q.where('next_attempt_at', Q.lte(Date.now())),
|
|
).fetch();
|
|
} catch (error) {
|
|
logDebug('DraftSyncManager.drainOutbox: outbox query failed', serverUrl);
|
|
return;
|
|
}
|
|
|
|
for (const row of rows) {
|
|
// Scope gate: only this baseline's team, or DM/GM ('' team) rows.
|
|
if (row.teamId !== baseline.teamId && row.teamId !== '') {
|
|
continue;
|
|
}
|
|
|
|
// A '' team is a DM/GM scope ONLY. Validate before draining under this arbitrary baseline's
|
|
// team: a row mis-stamped '' for a since-resolved channel must not drain here. Require the
|
|
// channel to be a DM/GM or to have confirmed membership; skip otherwise.
|
|
if (row.teamId === '') {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const validScope = await this.isDmGmOrMember(database, row.channelId);
|
|
if (!validScope) {
|
|
// Do NOT bare-continue: the row stays Pending-and-due, so scheduleWork would re-arm a
|
|
// zero-delay drain that skips it again — a busy loop. Back its next_attempt_at off so
|
|
// it leaves the due set until its channel/membership may have hydrated (a valid drain
|
|
// resets the count). The delay caps at DRAFT_SYNC_RETRY_MAX_MS for a truly orphaned row.
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await mutateDraftAndOutbox(database, row.channelId, row.rootId, ({outbox: o}) => {
|
|
if (!o || o.status !== DraftOutboxStatus.Pending) {
|
|
return [];
|
|
}
|
|
return [o.prepareUpdate((x) => {
|
|
x.attemptCount += 1;
|
|
x.nextAttemptAt = computeNextAttemptAt(x.attemptCount, Date.now());
|
|
})];
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const key = buildDraftOutboxId(row.channelId, row.rootId);
|
|
if (inFlight.has(key)) {
|
|
continue;
|
|
}
|
|
|
|
inFlight.add(key);
|
|
const captured = this.captureEpoch(serverUrl);
|
|
const opts: OutboxWorkerOpts = {
|
|
shouldAbort: () => this.isEpochStale(serverUrl, captured) || !this.isActive(serverUrl),
|
|
captureObservation: () => this.getObservation(serverUrl, row.channelId, row.rootId),
|
|
observationChanged: (c: number) => this.getObservation(serverUrl, row.channelId, row.rootId) !== c,
|
|
};
|
|
|
|
let outcome: WorkerOutcome;
|
|
try {
|
|
outcome = row.operation === DraftOutboxOperation.Delete ?
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await processOutboxDelete(serverUrl, row.channelId, row.rootId, opts) :
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await processOutboxUpsert(serverUrl, row.channelId, row.rootId, opts);
|
|
} catch (error) {
|
|
logDebug('DraftSyncManager.drainOutbox: worker threw', serverUrl);
|
|
outcome = {outcome: 'retry'};
|
|
} finally {
|
|
inFlight.delete(key);
|
|
}
|
|
|
|
// A concurrent invalidate/disable during the worker discards its result entirely.
|
|
if (this.isEpochStale(serverUrl, captured) || !this.isActive(serverUrl)) {
|
|
return;
|
|
}
|
|
|
|
if (outcome.outcome === 'suspend') {
|
|
// Server-wide failure: stop scheduling for this session and drop both timers.
|
|
this.enabled[serverUrl] = false;
|
|
this.clearRetryTimer(serverUrl);
|
|
this.clearReconcileTimer(serverUrl);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Re-evaluate timers: arm the drain timer for the earliest remaining Pending row and, if any
|
|
// confirming-delete/absence work remains, the delayed reconcile GET.
|
|
await this.scheduleWork(serverUrl);
|
|
};
|
|
|
|
// clearRetryTimer: cancels and forgets the per-server drain timer if present.
|
|
private clearRetryTimer = (serverUrl: string): void => {
|
|
const timer = this.retryTimers[serverUrl];
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
delete this.retryTimers[serverUrl];
|
|
}
|
|
};
|
|
|
|
// clearReconcileTimer: cancels and forgets the per-server reconcile-GET timer if present.
|
|
private clearReconcileTimer = (serverUrl: string): void => {
|
|
const timer = this.reconcileTimers[serverUrl];
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
delete this.reconcileTimers[serverUrl];
|
|
}
|
|
};
|
|
|
|
// ensureServer: lazily creates the per-server state entry (idempotent).
|
|
private ensureServer = (serverUrl: string): void => {
|
|
if (!(serverUrl in this.lifecycleEpoch)) {
|
|
this.lifecycleEpoch[serverUrl] = 0;
|
|
}
|
|
if (!(serverUrl in this.eventBuffers)) {
|
|
this.eventBuffers[serverUrl] = [];
|
|
}
|
|
if (!(serverUrl in this.enabled)) {
|
|
this.enabled[serverUrl] = false;
|
|
}
|
|
if (!(serverUrl in this.reconcileInFlight)) {
|
|
this.reconcileInFlight[serverUrl] = false;
|
|
}
|
|
if (!(serverUrl in this.reconcileAttempt)) {
|
|
this.reconcileAttempt[serverUrl] = 0;
|
|
}
|
|
if (!(serverUrl in this.lastReconcile)) {
|
|
this.lastReconcile[serverUrl] = {teamId: '', reason: ''};
|
|
}
|
|
if (!(serverUrl in this.reconcilePending)) {
|
|
this.reconcilePending[serverUrl] = undefined;
|
|
}
|
|
if (!(serverUrl in this.absenceCandidates)) {
|
|
this.absenceCandidates[serverUrl] = new Map();
|
|
}
|
|
if (!(serverUrl in this.inFlightKeys)) {
|
|
this.inFlightKeys[serverUrl] = new Set();
|
|
}
|
|
if (!(serverUrl in this.observationOrdinals)) {
|
|
this.observationOrdinals[serverUrl] = new Map();
|
|
}
|
|
};
|
|
|
|
// isActive: the server is present, enabled, and not invalidated (invalidate sets enabled=false).
|
|
private isActive = (serverUrl: string): boolean => {
|
|
return Boolean(this.enabled[serverUrl]) && (serverUrl in this.eventBuffers);
|
|
};
|
|
|
|
// getDatabase: the server database or undefined when it is absent/destroyed. Never throws.
|
|
private getDatabase = (serverUrl: string): Database | undefined => {
|
|
return DatabaseManager.serverDatabases[serverUrl]?.database;
|
|
};
|
|
|
|
// isDmGmOrMember: whether a channel is a valid '' (DM/GM) drain scope — a DM/GM channel, or any
|
|
// channel the current user has a confirmed membership row for.
|
|
private isDmGmOrMember = 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));
|
|
};
|
|
|
|
// bumpObservation: increment the per-key observation ordinal (a reconcile GET observed this key).
|
|
private bumpObservation = (serverUrl: string, channelId: string, rootId: string): void => {
|
|
const map = this.observationOrdinals[serverUrl];
|
|
if (!map) {
|
|
return;
|
|
}
|
|
const key = buildDraftOutboxId(channelId, rootId);
|
|
map.set(key, (map.get(key) ?? 0) + 1);
|
|
};
|
|
|
|
// getObservation: the current per-key observation ordinal (0 when never observed).
|
|
private getObservation = (serverUrl: string, channelId: string, rootId: string): number => {
|
|
return this.observationOrdinals[serverUrl]?.get(buildDraftOutboxId(channelId, rootId)) ?? 0;
|
|
};
|
|
}
|
|
|
|
const DraftSyncManager = new DraftSyncManagerSingleton();
|
|
export default DraftSyncManager;
|
|
|
|
export const exportedForTesting = {
|
|
DraftSyncManagerSingleton,
|
|
};
|
|
|
|
// ManagerBaselineInternals: narrow view onto the private baseline/single-flight state so tests can
|
|
// assert reconciliation bookkeeping without adding production-only accessors.
|
|
export type ManagerBaselineInternals = {
|
|
baseline: Record<string, {teamId: string; at: number}>;
|
|
reconcileInFlight: Record<string, boolean>;
|
|
reconcilePending: Record<string, {teamId: string} | undefined>;
|
|
absenceCandidates: Record<string, Map<string, {firstAbsentAt: number; teamId: string}>>;
|
|
};
|