/** * PendingOp contract. * * A PendingOp tracks an in-flight visibility state change from the moment * the GM issues the intent until the authoritative echo is received (or * the 3-second timeout fires and triggers a revert). * * Lifecycle: * create → register in Map * echo received → delete from map + clearTimeout(timeoutId) * 3s timeout fires → revert to previousState + GM notification * * @module contracts/pending-op */ /** Shape version constant for PendingOp. @type {1} */ export const PENDING_OP_VERSION = 1; /** * @typedef {Object} PendingOp * @property {string} opId - Unique operation identifier (non-empty string). * @property {string} userId - Target participant userId (non-empty string). * @property {string} targetState - Desired VisibilityState (non-empty string). * @property {string} previousState - State before this op; used for revert (non-empty string). * @property {number} issuedAt - Timestamp (ms) when op was issued — Date.now() integer. * @property {number|null} timeoutId - setTimeout handle; null if timeout not yet set. */ /** * Creates a new PendingOp. * @param {string} opId - Unique operation identifier. * @param {string} userId - Target participant userId. * @param {string} targetState - Desired VisibilityState. * @param {string} previousState - State before this op. * @param {number} [issuedAt] - Timestamp; defaults to Date.now(). * @returns {PendingOp} */ export function createPendingOp(opId, userId, targetState, previousState, issuedAt = Date.now()) { return { opId, userId, targetState, previousState, issuedAt, timeoutId: null }; } /** * Validates a PendingOp DTO. Throws TypeError on any violation. * @param {unknown} data - Value to validate. * @returns {PendingOp} The validated PendingOp. * @throws {TypeError} If data fails validation. */ export function isValidPendingOp(data) { if (data === null || typeof data !== "object") { throw new TypeError("PendingOp: must be an object"); } const obj = /** @type {Record} */ (data); const { opId, userId, targetState, previousState, issuedAt, timeoutId, ...rest } = obj; if (Object.keys(rest).length > 0) { throw new TypeError(`PendingOp: unknown keys: ${Object.keys(rest).join(", ")}`); } if (typeof opId !== "string" || opId.trim().length === 0) { throw new TypeError("PendingOp: opId must be a non-empty string"); } if (typeof userId !== "string" || userId.trim().length === 0) { throw new TypeError("PendingOp: userId must be a non-empty string"); } if (typeof targetState !== "string" || targetState.trim().length === 0) { throw new TypeError("PendingOp: targetState must be a non-empty string"); } if (typeof previousState !== "string" || previousState.trim().length === 0) { throw new TypeError("PendingOp: previousState must be a non-empty string"); } if (typeof issuedAt !== "number" || !Number.isFinite(issuedAt) || issuedAt < 0 || !Number.isInteger(issuedAt)) { throw new TypeError("PendingOp: issuedAt must be a finite non-negative integer"); } if (timeoutId !== null) { if (typeof timeoutId !== "number") { throw new TypeError("PendingOp: timeoutId must be a number or null"); } if (!Number.isFinite(timeoutId) || timeoutId < 0) { throw new TypeError("PendingOp: timeoutId must be a finite non-negative number"); } } return /** @type {PendingOp} */ (data); }