import { SqliteExecutionGrantStore } from "./execution-grant-store.js"; /** * SQLite implementation of SkillsProductStore. * * This is the zero-config default: a single operator runs `skills-server` with nothing * configured and gets a durable database at ~/.hasna/skills/server.db. Point * HASNA_SKILLS_DATABASE_URL at a postgres:// URL and the same server becomes the shared * multi-worker deployment. The database is an adapter choice, not a product variant - * the schema shape, the org scoping, and the run lifecycle are identical either way. * * Semantics are matched to PostgresSkillsStore method for method, including the places * where Postgres does something arguably odd (updateRun is not org-scoped; it is the * worker's write path and the worker has already been handed the run). Parity is the * requirement; changing Postgres behaviour is not this module's job. */ import { Database } from "bun:sqlite"; import { SqliteSkillSelectionStore } from "./selection-store.js"; import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, PublishedSkillSelection, PublishedSkillSelectionState, RunTransitionPatch, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillVersion, ApiKeyScopeUpdateResult, OperatorScopeEnrollmentInput, OperatorScopeEnrollmentResult, OperatorScopeTargetSnapshot, ServerSkillRecord, SkillLifecyclePatch, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js"; export interface SqliteStoreOptions { /** Apply pending migrations on open. Default true - it is what makes zero-config work. */ migrate?: boolean; /** Override the migrations directory. Tests and embedders only. */ migrationsDir?: string; /** * How long a writer waits for another connection's write lock before giving up. * * Not decoration. SQLite serialises writers; without a busy timeout a second worker * process calling claimNextRun() at the same moment gets SQLITE_BUSY immediately and * the claim fails rather than queueing. Five seconds is far longer than a claim * transaction (three statements, no I/O beyond the page cache) can plausibly take. */ busyTimeoutMs?: number; } export declare class SqliteSkillsStore implements SkillsProductStore { get selectionStore(): SqliteSkillSelectionStore; get executionGrantStore(): SqliteExecutionGrantStore; readonly backend: StoreBackendInfo; private db; private closed; constructor(path?: string, options?: SqliteStoreOptions); /** * Give rows written before migration 0004 a real content revision id. * * The migration adds revision_id with DEFAULT '', which would make If-Match vacuous * for legacy rows (every stale client matches the same empty string). This replaces * the marker with a content sha, idempotently: new code always writes a full id, so * the marker never reappears. Mirrors PostgresSkillsStore.backfillLegacyRevisions. */ private backfillLegacyRevisions; /** Escape hatch for tests and for tooling that needs raw SQL against the same handle. */ get database(): Database; close(): Promise; ensureBootstrapApiKey(token: string, principal?: Partial): Promise; authenticateApiKeyHash(hash: string): Promise; updateApiKeyScopes(actor: ApiPrincipal, keyId: string, expectedScopes: string[], addScopes: string[]): Promise; enrollPublishScopeByOperator(input: OperatorScopeEnrollmentInput): Promise; inspectOperatorScopeTarget(keyId: string, orgId: string): Promise; createRun(input: CreateRunInput): Promise; listRuns(principal: ApiPrincipal, limit: number): Promise; getRun(principal: ApiPrincipal, id: string): Promise; /** * Claim the oldest runnable job for a worker, exactly once. * * Postgres does this with `FOR UPDATE SKIP LOCKED`: the row is locked at SELECT time, * and a competing transaction skips past it to the next candidate. SQLite has no row * locks and no SKIP LOCKED - it has one write lock for the whole database - so the * equivalent guarantee has to be built from what SQLite does have: * * 1. BEGIN IMMEDIATE takes the database's RESERVED write lock up front, rather than * at the first write the way a deferred transaction does. Two claimers therefore * serialise from their first statement, not from their UPDATE, which is what * closes the read-then-write race. A plain BEGIN would let both claimers run * their SELECT, both see the same row, and one of them fail late (or, in WAL * mode, succeed - both having decided they own the run). * 2. The UPDATE claims BY ID and re-asserts the status predicate * (`AND status IN ('queued','retrying')`). A claim is only real if that statement * reports changes === 1. This makes exclusivity a property of the write itself * rather than a property of the surrounding transaction, so it holds even if the * isolation above were ever weakened. * 3. A bounded retry moves to the next candidate when changes === 0, so losing a * race never reports "queue empty" while runnable work is sitting there. * * Not org-scoped, matching Postgres: a worker serves every org on the instance. The * org boundary is enforced on the read paths a principal can reach. */ claimNextRun(input: ClaimRunInput): Promise; private claimOnce; updateRun(id: string, patch: Partial>): Promise; /** * Generation-fenced transition: the WHERE re-asserts the caller's expected * lease_generation, so a write from a worker whose claim was fenced (by a * cancellation, or by a newer claim) reports zero rows and is refused. * * Unlike updateRun, the patch may also move lease_generation - that is how * the cancel service fences the current worker. The fence bump and the * status move land in the same statement, so there is no instant where the * status says cancelled and the generation still admits the old worker. */ transitionRun(id: string, patch: RunTransitionPatch, expectedGeneration: number): Promise; appendLog(id: string, orgId: string, level: ServerRunLog["level"], message: string): Promise; listLogs(principal: ApiPrincipal, id: string): Promise; addArtifact(artifact: Omit): Promise; listArtifacts(principal: ApiPrincipal, id: string): Promise; getArtifact(principal: ApiPrincipal, id: string, artifact: string): Promise; publishSkill(input: PublishSkillInput): Promise; listSkills(principal: ApiPrincipal): Promise; getSkill(principal: ApiPrincipal, slug: string): Promise; setSkillLifecycle(principal: ApiPrincipal, slug: string, patch: SkillLifecyclePatch, expectedRevisionId?: string): Promise; private getSkillSync; updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch, expectedRevisionId?: string): Promise; deleteSkill(principal: ApiPrincipal, slug: string, tombstoneWindowMs: number): Promise; purgeExpiredTombstones(principal: ApiPrincipal): Promise; getSkillBundle(principal: ApiPrincipal, sha256: string): Promise; listSkillVersions(principal: ApiPrincipal, slug: string): Promise; getSkillVersion(principal: ApiPrincipal, slug: string, version: string): Promise; getPublishedSelectionStates(principal: ApiPrincipal, selections: readonly PublishedSkillSelection[]): Promise; pinSkill(principal: ApiPrincipal, slug: string, metadata?: Record): Promise; unpinSkill(principal: ApiPrincipal, slug: string): Promise; listPins(principal: ApiPrincipal): Promise; listTags(principal: ApiPrincipal): Promise; listSkillsByTag(principal: ApiPrincipal, tag: string): Promise; listPinsByTag(principal: ApiPrincipal, tag: string): Promise; listPublishedSlugs(principal: ApiPrincipal): Promise; /** * Drop a bundle no remaining skill in the org points at. * * The reference count is over skills_registry rather than a stored counter: a counter * would be a second source of truth for something one COUNT(*) answers exactly, and a * drifted counter either leaks blobs forever or deletes a bundle still in use. */ private collectOrphanBundle; private get; private all; private rollbackQuietly; } /** * Apply pending migrations/sqlite/*.sql to an open database. * * The applied-version key is the file's basename, identical to the Postgres migrator's, * so moving migrations/0001_*.sql into migrations/postgres/ did not orphan any database * that had already applied it. */ export declare function applySqliteMigrations(db: Database, migrationsDir?: string): string[];