import type { StateClient } from './context'; /** * Echo-suppression helpers for bidirectional sync plugins. * * The problem: a plugin's own inbound write (createTask/updateTask) fires a * task.create/task.update event, whose outbound leg would write the change * straight back to the external system. That write bumps the external * `updated` timestamp, which the next inbound pass sees as "newer than the * local task", updates the task again, and the cycle never converges. * * Two guards terminate the loop: * * 1. Outbound ({@link isAlreadySyncedLocalChange}): every inbound write stamps * the resulting local `lastUpdate` into the mapping metadata as * `timesheetUpdatedAt`. A local change that is not newer than the stamp is * the echo of our own write and must be skipped (canonical skip reason: * `already-synced-task-change`). * 2. Inbound ({@link isStaleExternalChange}): every outbound/inbound write * records the external `updated` timestamp in the mapping metadata. An * external change that is not newer than the recorded timestamp (or the * local entity's own `lastUpdate`) has already been applied and must be * skipped. * * {@link syncMetadataStamp} builds the canonical metadata fragment both guards * read. State-based `ifAbsent` locks ({@link tryAcquireStateLock}) close the * remaining race: a webhook-driven import racing a scheduled/manual full sync * creating the same local entity twice. */ /** Canonical metadata key holding the local lastUpdate stamped at sync time. */ export declare const METADATA_LOCAL_UPDATED_KEY = "timesheetUpdatedAt"; /** Default metadata key holding the external updated timestamp. */ export declare const METADATA_EXTERNAL_UPDATED_KEY = "updated"; /** Millis from a local entity's `lastUpdate`, tolerating string values. 0 when absent/invalid. */ export declare function getLastUpdateMillis(entity?: { lastUpdate?: number | string; } | null): number; export declare function readMetadataNumber(metadata: Record | null | undefined, key: string): number; export declare function readMetadataString(metadata: Record | null | undefined, key: string): string | undefined; /** * Outbound echo guard: true when a local change is not newer than the * `timesheetUpdatedAt` stamp recorded by this plugin's own last write, i.e. * the change event is the echo of that write. Callers skip the outbound sync * with reason `already-synced-task-change`. */ export declare function isAlreadySyncedLocalChange(metadata: Record | null | undefined, localLastUpdateMillis: number): boolean; /** * Inbound echo guard: true when the external change is not newer than the * external `updated` timestamp recorded in the mapping metadata and/or the * local entity's `lastUpdate`. Absent inputs never veto: each comparison only * applies when both sides are present and parseable. */ export declare function isStaleExternalChange(input: { metadata?: Record | null; /** Metadata key holding the recorded external timestamp. Defaults to `updated`. */ metadataKey?: string; /** External updated timestamp: ISO 8601 / RFC 3339 string or millis. */ externalUpdatedAt: string | number | null | undefined; /** Local entity `lastUpdate` millis (see {@link getLastUpdateMillis}). */ localLastUpdateMillis?: number; }): boolean; /** * Canonical metadata fragment recording what was just synced; spread into the * mapping metadata on every upsert so the two guards can read it back. */ export declare function syncMetadataStamp(input: { /** Local entity `lastUpdate` millis at write time (falls back to now when 0/absent). */ localLastUpdateMillis?: number; /** External updated timestamp as reported by the external system. */ externalUpdatedAt?: string | number | null; /** Metadata key for the external timestamp. Defaults to `updated`. */ externalUpdatedKey?: string; }): Record; /** True for the state-store conflict raised when an `ifAbsent` set loses the race. */ export declare function isStateConflictError(err: unknown): boolean; /** * Acquire a TTL-bounded `ifAbsent` state lock. Returns false when another * execution holds the lock; rethrows anything that is not the conflict error. */ export declare function tryAcquireStateLock(state: StateClient, key: string, ttlSeconds: number): Promise; /** Best-effort lock release; swallow errors so cleanup never masks the original failure. */ export declare function releaseStateLock(state: StateClient, key: string): Promise;