import type { Session } from "neo4j-driver"; import type { DispatchDestination } from "./connector-sync-provision.js"; export interface BookingRow { bookingId: string; slotStart: string; slotEnd: string; name: string; email: string; note?: string; } export interface ReconcileResult { created: boolean; meetingId: string; } export declare function bookingUid(bookingId: string): string; export declare function reconcileOne(session: Session, accountId: string, booking: BookingRow): Promise; /** * The single definition of a row the reconcile loop acts on. * * `status='accepted'` is the row the booking page captured. `kind='booking'` is * the row that must become a :Meeting — a `kind='block'` row only reserves a * slot and has no meeting by design, so counting it as unreconciled is a false * positive that is indistinguishable in the log from a genuinely lost booking. * * Both call sites build their statements from this constant. Two literals is * the defect: the sweep and the audit disagreed about what needed reconciling, * and only the sweep's opinion could act. */ export declare const ELIGIBLE_BOOKING_PREDICATE = "status='accepted' AND kind='booking'"; /** The one D1 read that serves both the sweep and the audit. `createdAt` is * read for the audit's age figure (Task 1896); the sweep does not use it. */ export declare const ELIGIBLE_BOOKING_SELECT = "SELECT bookingId, slotStart, slotEnd, name, email, note, attempts, createdAt FROM bookings WHERE status='accepted' AND kind='booking' ORDER BY id"; /** This account's booking :Meeting uids. Read once per run and used for both * the sweep's claim decision and the audit's counts. */ export declare function fetchBookingMeetingUids(session: Session, accountId: string): Promise; /** * The rows the sweep must claim: eligible, and with no :Meeting carrying their * uid. * * Task 1807 — this deliberately does not consult `swept`. `swept` records that * a write was attempted, not that a meeting exists; gating retry on it made a * booking whose meeting later went missing permanently unreachable, while the * audit reported it every tick. Re-entry is safe because `reconcileOne` is * idempotent on (accountId, uid) and returns created=false on a re-run. */ export declare function selectUnreconciled>(rows: T[], meetingUids: Set): T[]; /** The audit's view of one eligible row. `createdAt` is what turns the pending * count into a measurement: five rows pending two minutes and five pending * nine hours are the same number and must not be the same line (Task 1896). */ export interface EligibleBooking { bookingId: string; createdAt: string; } export interface AuditCounts { /** Eligible rows the audit looked at. Without it, pendingUnreconciled=0 out * of 0 (nothing to do) is indistinguishable from 0 out of 28 (verified * clean), and an audit that returns the same value for both is not an audit. */ eligibleExamined: number; pendingUnreconciled: number; orphanMeetings: number; /** * Whole minutes since the `createdAt` of the OLDEST pending row. * * — the largest age across pending rows with a parseable createdAt. * "unknown" — rows are pending but none carries a parseable createdAt. * * `0` is emitted for three distinct conditions, so it is never read alone: * nothing is pending; a row is pending but under a minute old; a createdAt is * ahead of the clock and clamps up. `pendingUnreconciled` on the same line * separates the first from the other two, which is what Task 1806 added it * for. It does NOT separate the second from the third, and nothing does; both * mean "pending, and not waiting long enough to matter". * * The "unknown" form is unreachable through either writer (the Pages Function * and BLOCK_ROW_INSERT both stamp `toISOString()`), and exists so a bad row * can never print `NaN` on a standing line. */ pendingOldestAgeMin: number | "unknown"; } /** * Compare D1's eligible booking rows against the account's booking :Meeting set. * pendingUnreconciled — eligible in D1 but no :Meeting (reconcile lagging or lost). * orphanMeetings — a booking :Meeting with no backing eligible D1 row. * pendingOldestAgeMin — how long the oldest of those pending rows has waited. * * Pure: both inputs are read by the caller, which uses the same two lists for * the sweep, so the sweep and the audit cannot disagree about the population. */ export declare function auditCounts(eligible: EligibleBooking[], meetingUids: string[], nowMs: number): AuditCounts; /** * Statements that bring an already-provisioned bookings table up to the schema * the sweep needs. Run once, on the first tick after upgrade, triggered by the * eligible-row read failing on the missing column. * * A template schema change alone cannot reach an existing install: schema.sql * is CREATE TABLE IF NOT EXISTS and is applied once at provisioning, so * re-applying it to a live database is a no-op. Without this the first * kind-qualified read would throw on every existing install and reconcile would * stop entirely — strictly worse than the defect being fixed. * * The third statement classifies rows that predate the column. It is the only * place the platform reads a block row's note: the marker is a free-text string * owned by an out-of-tree script (Task 1811 absorbs that writer, after which * kind='block' is set natively), so it may be used to interpret legacy rows once * but never to decide control flow. It is safe to re-run. */ export declare const BOOKING_SCHEMA_MIGRATION: string[]; /** True when a D1 failure is the absent-column case the migration answers. * Every other failure must stay a failure. */ export declare function isMissingColumnError(message: string): boolean; /** True when an ALTER failed because the column is already there. SQLite has no * ADD COLUMN IF NOT EXISTS, so each statement tolerates this independently and * a half-migrated table completes rather than aborting. */ export declare function isDuplicateColumnError(message: string): boolean; /** The exact instruction the recurring connector-sync admin turn runs. Silent on * a clean run: the operator only hears from it on a failure or a change worth * flagging. The op=cal-sync stderr line is the standing proof, not a chat reply. */ export declare const CONNECTOR_SYNC_PROMPT: string; /** * Create-only provisioning of the recurring connector-sync :Event. Idempotent: * the existence probe keys on the `connectorSync` marker so a re-run never * duplicates the event nor resets its advancing nextRun. Returns 'provisioned' * on the first arming, 'already-provisioned' thereafter. * * Task 1809 — `dispatch` is the resolved EXCEPTION destination, not a gate. The * caller decides whether to provision from the presence of a calendar * connector (the sync's actual input) and resolves where an exception is * reported separately; this function writes whichever channel it is handed. */ export declare function ensureConnectorSyncEvent(session: Session, accountId: string, dispatch: DispatchDestination): Promise<"provisioned" | "already-provisioned">; export interface BusyInterval { start: string; end: string; } /** Which of the three states the drift comparison was actually in. `verified` is * the only one where `conflicts` carries information: on the other two the * comparison had nothing to compare and its 0 is vacuous. */ export type PublishedDriftOutcome = "verified" | "no-busy-data" | "no-published-slots"; export interface PublishedDrift { outcome: PublishedDriftOutcome; /** Size of the published-open set the comparison ran against. */ published: number; /** Size of the connector-busy set the comparison ran against. */ busy: number; /** Published-open slots overlapping any busy interval. Necessarily 0 on the * two degraded outcomes, which is why the outcome token has to be read. */ conflicts: number; } /** * Classify published-open booking slots against the connector-busy set — the * drift the standing audit reports. `outcome: "verified"` with a nonzero * `conflicts` means the booking page is currently offering a slot the connector * shows busy. An empty published set reports `no-published-slots` even when the * busy set is also empty: the site never publishing is the upstream condition. */ export declare function classifyPublishedDrift(published: BusyInterval[], busy: BusyInterval[]): PublishedDrift; /** A slot as it appears in the published `availability_snapshot` payload. */ export interface PublishedSlot { start: string; end: string; } /** * The published slots to block, given the connector-busy set. * * The returned slots are drawn from `published` verbatim, never synthesised * from `busy`: the edge free/busy Function subtracts taken slots by an exact * `slotStart` string match, so a block row keyed on a busy-interval boundary * would block nothing. * * Two exclusions, both carried over from the out-of-tree script this replaces: * a slot that has already started (the edge filters past slots anyway, so a * row for one is inert churn), and a slot already taken by a real booking * (already subtracted at the edge by that booking). */ export declare function selectBlockedSlots(published: PublishedSlot[], busy: BusyInterval[], taken: Set, nowMs: number): PublishedSlot[]; /** A block row as held in D1. `bookingId` is the delete key. */ export interface BlockRow { bookingId: string; slotStart: string; } export interface BlockDiff { toInsert: PublishedSlot[]; toDelete: string[]; } /** * Reconcile the held block rows to the desired set, keyed on slot start. * * This replaces the out-of-tree script's delete-and-reinsert. That form * rewrote every row on every run and left a window in which a still-busy slot * was unblocked; this one writes nothing in the steady state and never * unblocks a slot that is staying blocked. * * A slot held by more than one row (possible after an interrupted run) keeps * one and deletes the rest, so a duplicate converges instead of persisting. */ export declare function diffBlockRows(desired: PublishedSlot[], current: BlockRow[]): BlockDiff; /** The block rows this dispatcher owns. `kind='block'` is the whole identity — * no free-text marker is consulted, which is the defect Task 1811 removes. */ export declare const BLOCK_ROWS_SELECT = "SELECT bookingId, slotStart FROM bookings WHERE kind='block'"; /** `status='accepted'` so the edge free/busy Function subtracts the slot; * `kind='block'` so 1806's sweep and audit both exclude it by construction. */ export declare const BLOCK_ROW_INSERT: string; /** The `kind` qualifier is a safety belt, not a filter: even handed the id of a * real booking this statement cannot delete it. */ export declare const BLOCK_ROW_DELETE = "DELETE FROM bookings WHERE bookingId=?1 AND kind='block'"; /** Positional parameters for BLOCK_ROW_INSERT. The slot bounds are copied from * the published snapshot verbatim — the edge matches `slotStart` exactly. */ export declare function blockInsertParams(slot: PublishedSlot, bookingId: string, nowIso: string): string[]; /** The closed vocabulary of the `op=cal-block` outcome field. Deliberately the * same tokens as `op=cal-drift` so an operator reads one vocabulary across the * sibling lines. Typed so a mistyped literal fails the build. */ export type BlockOutcome = PublishedDriftOutcome | "snapshot-unreadable" | "error"; export interface BlockPlan { outcome: BlockOutcome; diff: BlockDiff; } /** * Decide what the blocking step should write. Pure, so the safety rule below is * testable without a live D1 or Neo4j — the rule is the highest-consequence * line in this feature and was previously enforced only by an `if` at the call * site that no test could reach. * * Writes happen ONLY on `verified`: the snapshot was read, it holds slots, and * the busy set holds at least one usable interval. Every degraded outcome * returns no writes, which means an absent or unreadable busy set RETAINS the * rows already held rather than clearing them. * * That asymmetry is deliberate. An empty busy set is ambiguous between a * genuinely clear calendar and a sync that failed, never ran, or was * deauthorised — `syncConnectorBusy` writes an empty set on the documented * deauthorise path. Over-blocking is recoverable by the operator; under-blocking * double-books a customer against a real commitment. The retained rows are * reported on the `op=cal-block` line so the condition is visible, not silent. */ export declare function planBlockWrites(input: { /** False when the snapshot could not be read at all, as distinct from a * snapshot that was read and holds no slots. */ publishedReadable: boolean; /** False when the busy set could not be read at all (the graph read threw). */ busyReadable: boolean; published: PublishedSlot[]; busy: BusyInterval[]; taken: Set; current: BlockRow[]; nowMs: number; }): BlockPlan; //# sourceMappingURL=booking-reconcile.d.ts.map