/** * The presence & registry store — S2's durable layer over the app DataLayer. * * S1 owns *connection* liveness in-memory (touch-on-frame + a TTL sweep that * closes silent sockets). S2 layers a durable *presence* registry on top: one * row per registered worker instance, carrying its declared capability * (cognition/weight/family/host — an enrolment attribute, never a routing * token), the connection it registered on, and its own `last_seen` liveness * refreshed by heartbeats. Rows age out on the presence TTL via {@link sweep}. * * The store speaks only the tiny synchronous SQLite subset the runtime exposes * ({@link SqliteDb}), so it works against any app DataLayer source without * pulling in the whole runtime. */ import type { Capability } from "../protocol/index.ts"; import type { Clock } from "../channel/index.ts"; /** * The minimal synchronous SQLite handle the store needs — structurally the same * surface the Urban runtime's DataLayer exposes (`host.openSqlite`). Kept local * so the store depends on a shape, not on the runtime package. */ export interface SqliteDb { /** Execute one or more statements with no result (DDL, migrations). */ exec(sql: string): void; /** Run a parameterised statement, returning the changed-row count. */ run(sql: string, params?: unknown[]): { changes: number; lastInsertRowid: number | bigint; }; /** Run a parameterised query, returning all rows as plain objects. */ all>(sql: string, params?: unknown[]): T[]; } /** A durable presence row for one registered worker instance. */ export interface PresenceRow { /** The worker instance id (`register.instance`) — the primary key. */ readonly instance: string; /** The channel connection the instance last registered on. */ readonly connectionId: string; /** The authenticated principal (ADR 0028 identity) of that connection. */ readonly identity: string; /** The declared enrolment capability (never a routing token). */ readonly capability: Capability; /** When the instance first registered, ISO-8601. */ readonly registeredAt: string; /** Last liveness refresh (register/heartbeat), epoch ms. */ readonly lastSeen: number; } /** Input to {@link PresenceStore.register}. */ export interface RegisterInput { readonly instance: string; readonly connectionId: string; readonly identity: string; readonly capability: Capability; } export interface PresenceStoreOptions { /** Presence liveness TTL in ms; a row unseen for longer ages out. Default 30000. */ ttlMs?: number; /** Injectable clock for deterministic tests. Default {@link systemClock}. */ clock?: Clock; } /** * Raised when a `register` would overwrite an instance already owned by a * different authenticated identity. Presence rows are bound to the identity * that first registered them, so one authenticated peer can never take over * (or change the identity of) another peer's instance. */ export declare class PresenceOwnershipError extends Error { readonly instance: string; constructor(instance: string); } export declare class PresenceStore { #private; constructor(db: SqliteDb, options?: PresenceStoreOptions); /** The presence liveness TTL in ms. */ get ttlMs(): number; /** * Apply the canonical presence DDL (idempotent). Callers that let the app * DataLayer migration runner apply `db/migrations/001_agentic_presence.sql` * do not need this — but the family module calls it so the store is usable * against a bare source too. The DDL is identical to the migration (guarded). */ ensureSchema(): void; /** * Register (or re-register) an instance. A first registration stamps * `registered_at`; a re-registration (e.g. after a reconnect on a new * connection) keeps the original `registered_at` and refreshes everything * else, including `last_seen`. Returns the stored row. * * Ownership is bound to the authenticated `identity` that first registered the * instance: a re-register from the same identity (the reconnect case) is * allowed, but one from a *different* identity is rejected with a * {@link PresenceOwnershipError} and leaves the existing row untouched — no * peer can take over another peer's instance or rewrite its identity. */ register(input: RegisterInput): PresenceRow; /** * Refresh an instance's liveness to now. Returns `true` if the instance was * registered, `false` if there is no such row (a heartbeat before register). * When `identity` is given, the refresh is scoped to the owning identity, so a * heartbeat from a foreign identity is a silent no-op (and cannot probe for * the existence of another peer's instance). */ heartbeat(instance: string, identity?: string): boolean; /** * Remove an instance's presence row. Returns `true` if a row was removed. When * `identity` is given, the removal is scoped to the owning identity, so a * deregister from a foreign identity is a silent no-op. */ deregister(instance: string, identity?: string): boolean; /** * Remove every presence row registered on a now-dead connection (e.g. one S1 * closed on its own liveness sweep). Returns the removed instance ids so the * caller can react. Presence also ages out via {@link sweep}; this is the * eager path when a disconnect is observed. */ removeByConnection(connectionId: string): string[]; /** * Age out every presence row whose `last_seen` is older than the TTL and * return the removed rows. `now` defaults to the clock; pass an explicit value * for deterministic tests. Matches S1's liveness predicate (`now - lastSeen > * ttl`). */ sweep(now?: number): PresenceRow[]; /** Look up a single instance's presence row. */ get(instance: string): PresenceRow | undefined; /** Every presence row, ordered by first registration then instance id. */ list(): PresenceRow[]; /** Number of registered instances. */ count(): number; }