/** * Project locations (`nexus_project_locations`) — every place a project has * been seen, on every device. * * The registry is keyed by the immutable `project_id` alone (ADR-094 · * T12469); its `project_path` names only the checkout encountered most * recently. This table records every checkout as one row per * `(project_id, device_id, path)`, so two checkouts of one project coexist and * the same project on two devices keeps one registry row. * * Rows are never deleted because a directory vanished. When a project is * recorded, this device's other `live` locations of it whose directory is gone * move to `missing`; a location whose path now holds a different project moves * to `superseded`. History therefore survives for consumers that need former * paths (legacy path-derived credential candidates, T12475). * * Every writer that records a checkout in the registry records it here in the * same transaction: encounter registration, `nexusRegister`, `nexusReconcile` * and `nexusMoveProject`. Only an explicit unregister or clean removes rows. * * ## Compatibility with older binaries sharing the global store * * Older binaries still read the registry by path (`WHERE project_path = ? * LIMIT 1`, and an owner filter of path OR hash OR id). Two registry rows at * one path would make them pick a stale row and fail (`E_PROJECT_ID_DRIFT`, * identity conflicts). So when a path changes hands, the previous holder's * registry row is re-homed in the same transaction ({@link rehomeDisplacedRows}): * to its most recent other live location on this device, or, if it has none, * to the non-path sentinel {@link supersededRegistryPath} — which no path * lookup can match. At most one registry row ever names a real path. * * The legacy `nexus_project_paths` map is dual-written (upsert only) for one * release so older binaries keep reading a current map; drop it with a later * migration once those binaries are gone. * * @task T12354 * @task T12469 */ import type { NexusProjectCheckout } from '@cleocode/contracts'; import type { NodeSQLiteDatabase } from 'drizzle-orm/node-sqlite'; import type { CheckoutEvidence } from './identity.js'; /** A registry handle or an open transaction on one. */ export type PathMapWriter = Pick; /** * Device id carried by rows the T12469 migration backfilled. SQL cannot read * the device id, and the global store is device-local, so every such row * belongs to this device; {@link adoptLocalDeviceRows} re-keys them. */ export declare const LOCAL_DEVICE_SENTINEL = "local"; /** One checkout to record. */ export interface ProjectCheckoutRecord { /** Immutable project id. */ projectId: string; /** Absolute checkout root. */ projectPath: string; /** * Path fingerprint of the checkout. Accepted for caller compatibility; the * location row stores no hash — it is derived from the path when read. */ projectHash?: string; /** ISO 8601 timestamp of the encounter. */ now: string; /** Device the checkout is on. Defaults to this device's stable id. */ deviceId?: string; /** * Repository evidence for the checkout (T12470). DISPLAYED only — never a * proof of identity or of a move (forgeable). Absent fields keep * whatever evidence the row already carries. */ evidence?: CheckoutEvidence; /** * The checkout's nonce from its untracked `project-info.json` (T12470) — * the only proof of a move. Recorded on confirmed locations only. */ checkoutNonce?: string | null; /** * Holdings probed BEFORE the write, keyed by {@link holdingKey} (T12471). * A caller that must not touch the filesystem inside its transaction (a * hung mount would hold the registry's write lock) passes them here; any * path not in the map is probed synchronously as before. */ probed?: ReadonlyMap; } /** * Key of a {@link ProjectCheckoutRecord.probed} entry. * * @param projectId - Project the holding was probed for. * @param path - Probed path. * @returns The map key. * * @example * ```ts * probed.set(holdingKey(id, path), await probeProjectHolding(path, id, 2000)); * ``` */ export declare function holdingKey(projectId: string, path: string): string; /** What {@link recordProjectCheckout} changed besides the recorded location. */ export interface ProjectCheckoutOutcome { /** This project's other locations on this device newly marked `missing`. */ markedMissing: number; /** Other projects' locations at this path newly marked `superseded`. */ superseded: number; } /** * Return this device's stable id — the persisted `/device-id` * (T9321). Reused rather than minting a second device identity. * * @returns Opaque stable device id. */ export declare function currentDeviceId(): string; /** Prefix of the registry `project_path` sentinel for a row with no live location. */ export declare const SUPERSEDED_PATH_PREFIX = "superseded:"; /** * Registry `project_path` for a project whose last checkout now holds another * project and which has no other live location. Not an absolute path, so no * path lookup (current or older binary) can match it; unique per id. * * @param projectId - Immutable project id. * @returns `superseded:`. */ export declare function supersededRegistryPath(projectId: string): string; /** * Whether a registry `project_path` is the {@link supersededRegistryPath} sentinel. * * @param projectPath - Stored registry path. * @returns `true` for the sentinel. */ export declare function isSupersededRegistryPath(projectPath: string): boolean; /** Answer of {@link projectHolding}: `unknown` is never proof of absence. */ export type ProjectHolding = 'yes' | 'no' | 'unknown'; /** * Whether `path` still holds `projectId`: * * - `no` — PROVABLY not: `.cleo/` is absent (ENOENT / ENOTDIR), or it * declares a different id. A directory the project was moved or rerooted * out of (or whose `.cleo/` was moved back out by hand). * - `unknown` — cannot tell (EACCES, EPERM, EIO, …). A checkout made * unreadable by `chmod 000` is NOT gone; treating it as vanished would let a * same-nonce backup copy take its registry row (T12558 round 4). * - `yes` — `.cleo/` is there and declares this id (or nothing). */ export declare function projectHolding(path: string, projectId: string): ProjectHolding; /** * {@link projectHolding} with a time budget, for machine-wide scans * (T12471): a hung mount answers `unknown` after `timeoutMs` instead of * stalling the caller. Same tri-state rules — only ENOENT/ENOTDIR prove the * project gone. * * @param path - Checkout root to probe. * @param projectId - Id the checkout should hold. * @param timeoutMs - Budget for the directory probe. * @returns `yes`, `no`, or `unknown` (unreadable or timed out). * * @example * ```ts * if ((await probeProjectHolding(row.projectPath, row.projectId, 2000)) === 'no') markGone(row); * ``` */ export declare function probeProjectHolding(path: string, projectId: string, timeoutMs: number): Promise; /** * Re-key migration-backfilled rows (device id {@link LOCAL_DEVICE_SENTINEL}) * to `deviceId`, merging with any row already recorded under the real id. * * @param db - Registry handle or transaction. * @param deviceId - This device's stable id. * @returns Number of sentinel rows adopted. */ export declare function adoptLocalDeviceRows(db: PathMapWriter, deviceId: string): number; /** * Record a checkout as a `live` location, supersede other projects' claims on * the same path, and mark this project's vanished paths on this device * `missing`. Nothing is deleted. * * Runs synchronously so it composes with the registry writers' immediate * transactions. * * @param db - Registry handle or transaction. * @param record - The checkout encountered. * @returns Counts of rows moved to `missing` and `superseded`. */ export declare function recordProjectCheckout(db: PathMapWriter, record: ProjectCheckoutRecord): ProjectCheckoutOutcome; /** * How a per-command encounter may bind a checkout that declares `projectId` * (T12470). Only explicit commands bind unconditionally; an encounter is a * side effect of ANY command, including read-only ones, and the id it sees is * committed to git — so any directory can declare it. * * - `bind` — no registry row for the id yet, or the row already names this * path: record it as the live location. * - `refresh` — this path is already a confirmed (`live`) location of the id * on this device, but the row names another: refresh the location only. * - `promote` — a proven move: the row's path was recorded on THIS device, * is gone from disk, and the nonce recorded there equals the one in this * checkout's untracked `project-info.json`. The row follows the checkout. * - `candidate` — anything else: record an unconfirmed location. The registry * row, its path and its permissions are left untouched. */ export type EncounterBinding = 'bind' | 'refresh' | 'promote' | 'candidate'; /** * Decide how an encounter may bind a checkout (see {@link EncounterBinding}). * * @param db - Registry handle or transaction. * @param record - The encountered checkout and its evidence. * @returns The permitted binding. * * @example * ```ts * const binding = decideEncounterBinding(tx, { projectId, projectPath, now, evidence }); * ``` */ export declare function decideEncounterBinding(db: PathMapWriter, record: ProjectCheckoutRecord): EncounterBinding; /** * Record an UNCONFIRMED location of a project (T12470): the checkout declares * the id, but nothing has confirmed it. The registry row, its path and its * permissions are not touched, and no other project's claim on the path ends. * A location that is already `live` stays `live`. * * @param db - Registry handle or transaction. * @param record - The encountered checkout. * @returns `true` when the candidate was recorded for the first time. * * @example * ```ts * recordCandidateLocation(tx, { projectId, projectPath, now, evidence }); * ``` */ export declare function recordCandidateLocation(db: PathMapWriter, record: ProjectCheckoutRecord): boolean; /** * Demote a location this device no longer treats as the project's home * (T12556 · T12558). `cleo project move` leaves the source tree behind as a * `candidate` (it still declares the id but is no longer confirmed); * `cleo project reroot` leaves the old root with no `.cleo/` at all, so it is * `missing`. A location that was never recorded is recorded in that state so * the history is complete. The registry row is not touched. * * @param db - Registry handle or transaction. * @param record - The location to demote. * @param state - `candidate` (still declares the id) or `missing` (does not). * * @example * ```ts * demoteProjectLocation(tx, { projectId, projectPath: oldRoot, now }, 'missing'); * ``` */ export declare function demoteProjectLocation(db: PathMapWriter, record: ProjectCheckoutRecord, state: 'candidate' | 'missing'): void; /** * Refresh an already-confirmed location without repointing the registry row. * * @param db - Registry handle or transaction. * @param record - The encountered checkout. */ export declare function touchProjectLocation(db: PathMapWriter, record: ProjectCheckoutRecord): void; /** * Explicitly confirm a checkout as a project's live location and point the * registry row at it (T12470) — the promotion path for a `candidate` used by * `cleo doctor project-identity --resolve`. Permissions stay on the row. * * @param db - Registry handle or transaction. * @param record - The checkout to confirm. * @returns `false` when the project has no registry row to point. * * @example * ```ts * confirmProjectLocation(tx, { projectId, projectPath, now, evidence }); * ``` */ export declare function confirmProjectLocation(db: PathMapWriter, record: ProjectCheckoutRecord): boolean; /** * Keep at most one registry row per real path: move every OTHER project's * registry row that still names `projectPath` to that project's most recent * other live location on this device, or to its * {@link supersededRegistryPath} sentinel when it has none. * * @param db - Registry handle or transaction. * @param projectId - The project that now holds `projectPath`. * @param projectPath - The path that changed hands. * @param deviceId - This device's stable id. * @param probed - Holdings probed before the transaction ({@link holdingKey}). * @returns Number of registry rows re-homed. */ export declare function rehomeDisplacedRows(db: PathMapWriter, projectId: string, projectPath: string, deviceId: string, probed?: ReadonlyMap): number; /** * List every recorded location of a project, newest first. * * @param projectId - Immutable project id. * @param opts - `allDevices: true` includes other devices' locations; by * default only this device's are listed. * @returns Locations with their state and an `exists` flag (checked only for * this device's rows; `false` for another device's). * @example * ```ts * const checkouts = await listProjectCheckouts(projectId); * const live = checkouts.filter((c) => c.state === 'live'); * ``` */ export declare function listProjectCheckouts(projectId: string, opts?: { allDevices?: boolean; }): Promise; //# sourceMappingURL=path-map.d.ts.map