Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/skills

src/db.ts

402 lines · 9.8 KB · TypeScript
import { packageStorage } from 'kody:runtime'

/**
 * Always resolve the registry bucket through `packageStorage()`: it is bound
 * to THIS package's bucket in every context — the package's own runtime, and
 * statically imported copies of this code running in ad hoc execute or other
 * packages. Ambient `storage` would silently point at the caller's bucket
 * (or nothing) in those foreign contexts. Called per-use, never cached at
 * module scope, to respect the runtime's late-binding contract.
 */
function bucket() {
	return packageStorage()
}

let schemaReady = false

export type SkillRow = {
	id: string
	name: string
	description: string
	created_at: string
	updated_at: string
}

export type SkillFileRow = {
	skill_id: string
	path: string
	content: string
	updated_at: string
}

export type SkillVersionRow = {
	id: number
	skill_id: string
	path: string
	content: string
	replaced_at: string
}

export type SkillRevisionRow = {
	id: number
	commit_oid: string
	skill_id: string
	path: string
	replaced_at: string
	content_length: number
}

export type SkillsIndexRow = {
	id: string
	name: string
	description: string
	files_json: string
	created_at: string
	updated_at: string
}

export async function ensureSchema(): Promise<void> {
	if (schemaReady) return

	// Legacy document store — left intact after migration (copy-first).
	await bucket().sql(`
		CREATE TABLE IF NOT EXISTS skills (
			id TEXT PRIMARY KEY,
			name TEXT NOT NULL,
			description TEXT NOT NULL,
			created_at TEXT NOT NULL,
			updated_at TEXT NOT NULL
		)
	`)
	await bucket().sql(`
		CREATE TABLE IF NOT EXISTS skill_files (
			skill_id TEXT NOT NULL,
			path TEXT NOT NULL,
			content TEXT NOT NULL,
			updated_at TEXT NOT NULL,
			PRIMARY KEY (skill_id, path)
		)
	`)
	await bucket().sql(`
		CREATE TABLE IF NOT EXISTS skill_versions (
			id INTEGER PRIMARY KEY AUTOINCREMENT,
			skill_id TEXT NOT NULL,
			path TEXT NOT NULL,
			content TEXT NOT NULL,
			replaced_at TEXT NOT NULL
		)
	`)

	// Migration / backend pointer (legacy rows stay; this records the switch).
	await bucket().sql(`
		CREATE TABLE IF NOT EXISTS registry_meta (
			key TEXT PRIMARY KEY,
			value TEXT NOT NULL
		)
	`)

	// Fast list/search projection over the plain-repo durable home.
	await bucket().sql(`
		CREATE TABLE IF NOT EXISTS skills_index (
			id TEXT PRIMARY KEY,
			name TEXT NOT NULL,
			description TEXT NOT NULL,
			files_json TEXT NOT NULL,
			created_at TEXT NOT NULL,
			updated_at TEXT NOT NULL
		)
	`)

	// Maps skill_history version_id integers → git commits in the skills repo.
	await bucket().sql(`
		CREATE TABLE IF NOT EXISTS skill_revisions (
			id INTEGER PRIMARY KEY AUTOINCREMENT,
			commit_oid TEXT NOT NULL,
			skill_id TEXT NOT NULL,
			path TEXT NOT NULL,
			replaced_at TEXT NOT NULL,
			content_length INTEGER NOT NULL
		)
	`)

	schemaReady = true
}

export function nowIso(): string {
	return new Date().toISOString()
}

export async function sqlRows<T extends Record<string, unknown>>(
	query: string,
	params: Array<string | number | boolean | null> = [],
): Promise<T[]> {
	const result = await bucket().sql(query, params)
	return (result.rows ?? []) as T[]
}

export async function sqlRun(
	query: string,
	params: Array<string | number | boolean | null> = [],
): Promise<{ rowsWritten: number }> {
	const result = await bucket().sql(query, params)
	return { rowsWritten: result.rowsWritten ?? 0 }
}

export async function getMeta(key: string): Promise<string | null> {
	await ensureSchema()
	const rows = await sqlRows<{ value: string }>(
		`SELECT value FROM registry_meta WHERE key = ?`,
		[key],
	)
	return rows[0]?.value ?? null
}

export async function setMeta(key: string, value: string): Promise<void> {
	await ensureSchema()
	await sqlRun(
		`INSERT INTO registry_meta (key, value) VALUES (?, ?)
		 ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
		[key, value],
	)
}

export async function listSkillIds(): Promise<string[]> {
	await ensureSchema()
	const backend = await getMeta('backend')
	if (backend === 'repo') {
		const rows = await sqlRows<{ id: string }>(
			`SELECT id FROM skills_index ORDER BY id ASC`,
		)
		return rows.map((row) => row.id)
	}
	const rows = await sqlRows<{ id: string }>(
		`SELECT id FROM skills ORDER BY id ASC`,
	)
	return rows.map((row) => row.id)
}

export async function getSkill(id: string): Promise<SkillRow | null> {
	await ensureSchema()
	const backend = await getMeta('backend')
	if (backend === 'repo') {
		const rows = await sqlRows<SkillsIndexRow>(
			`SELECT id, name, description, files_json, created_at, updated_at
			 FROM skills_index WHERE id = ?`,
			[id],
		)
		const row = rows[0]
		if (!row) return null
		return {
			id: row.id,
			name: row.name,
			description: row.description,
			created_at: row.created_at,
			updated_at: row.updated_at,
		}
	}
	const rows = await sqlRows<SkillRow>(
		`SELECT id, name, description, created_at, updated_at FROM skills WHERE id = ?`,
		[id],
	)
	return rows[0] ?? null
}

export async function getSkillOrThrow(id: string): Promise<SkillRow> {
	const skill = await getSkill(id)
	if (!skill) {
		const ids = await listSkillIds()
		const available =
			ids.length > 0 ? ids.map((value) => `\`${value}\``).join(', ') : '(none)'
		throw new Error(
			`Skill not found: \`${id}\`. Valid skill ids: ${available}. Call skill_list for details.`,
		)
	}
	return skill
}

export async function listFilesForSkill(
	skillId: string,
): Promise<SkillFileRow[]> {
	await ensureSchema()
	return sqlRows<SkillFileRow>(
		`SELECT skill_id, path, content, updated_at
		 FROM skill_files
		 WHERE skill_id = ?
		 ORDER BY path ASC`,
		[skillId],
	)
}

export async function getFile(
	skillId: string,
	path: string,
): Promise<SkillFileRow | null> {
	await ensureSchema()
	const rows = await sqlRows<SkillFileRow>(
		`SELECT skill_id, path, content, updated_at
		 FROM skill_files
		 WHERE skill_id = ? AND path = ?`,
		[skillId, path],
	)
	return rows[0] ?? null
}

export async function snapshotFile(
	skillId: string,
	path: string,
	content: string,
	replacedAt: string = nowIso(),
): Promise<void> {
	await ensureSchema()
	await sqlRun(
		`INSERT INTO skill_versions (skill_id, path, content, replaced_at)
		 VALUES (?, ?, ?, ?)`,
		[skillId, path, content, replacedAt],
	)
}

export async function upsertSkillsIndex(input: {
	id: string
	name: string
	description: string
	files: string[]
	created_at: string
	updated_at: string
}): Promise<void> {
	await ensureSchema()
	await sqlRun(
		`INSERT INTO skills_index (id, name, description, files_json, created_at, updated_at)
		 VALUES (?, ?, ?, ?, ?, ?)
		 ON CONFLICT(id) DO UPDATE SET
			name = excluded.name,
			description = excluded.description,
			files_json = excluded.files_json,
			created_at = excluded.created_at,
			updated_at = excluded.updated_at`,
		[
			input.id,
			input.name,
			input.description,
			JSON.stringify(input.files),
			input.created_at,
			input.updated_at,
		],
	)
}

export async function deleteSkillsIndex(id: string): Promise<void> {
	await ensureSchema()
	await sqlRun(`DELETE FROM skills_index WHERE id = ?`, [id])
}

export async function listSkillsIndex(): Promise<SkillsIndexRow[]> {
	await ensureSchema()
	return sqlRows<SkillsIndexRow>(
		`SELECT id, name, description, files_json, created_at, updated_at
		 FROM skills_index
		 ORDER BY id ASC`,
	)
}

export async function insertSkillRevision(input: {
	id?: number
	commit_oid: string
	skill_id: string
	path: string
	replaced_at: string
	content_length: number
}): Promise<number> {
	await ensureSchema()
	if (input.id != null) {
		await sqlRun(
			`INSERT OR REPLACE INTO skill_revisions
				(id, commit_oid, skill_id, path, replaced_at, content_length)
			 VALUES (?, ?, ?, ?, ?, ?)`,
			[
				input.id,
				input.commit_oid,
				input.skill_id,
				input.path,
				input.replaced_at,
				input.content_length,
			],
		)
		return input.id
	}
	await sqlRun(
		`INSERT INTO skill_revisions
			(commit_oid, skill_id, path, replaced_at, content_length)
		 VALUES (?, ?, ?, ?, ?)`,
		[
			input.commit_oid,
			input.skill_id,
			input.path,
			input.replaced_at,
			input.content_length,
		],
	)
	const rows = await sqlRows<{ id: number }>(
		`SELECT id FROM skill_revisions
		 WHERE commit_oid = ? AND skill_id = ? AND path = ?
		 ORDER BY id DESC LIMIT 1`,
		[input.commit_oid, input.skill_id, input.path],
	)
	return Number(rows[0]?.id)
}

export async function getSkillRevision(
	versionId: number,
): Promise<SkillRevisionRow | null> {
	await ensureSchema()
	const rows = await sqlRows<SkillRevisionRow>(
		`SELECT id, commit_oid, skill_id, path, replaced_at, content_length
		 FROM skill_revisions WHERE id = ?`,
		[versionId],
	)
	return rows[0] ?? null
}

export async function listSkillRevisions(input: {
	skill_id: string
	path?: string
	limit: number
}): Promise<SkillRevisionRow[]> {
	await ensureSchema()
	if (input.path) {
		return sqlRows<SkillRevisionRow>(
			`SELECT id, commit_oid, skill_id, path, replaced_at, content_length
			 FROM skill_revisions
			 WHERE skill_id = ? AND path = ?
			 ORDER BY id DESC
			 LIMIT ?`,
			[input.skill_id, input.path, input.limit],
		)
	}
	return sqlRows<SkillRevisionRow>(
		`SELECT id, commit_oid, skill_id, path, replaced_at, content_length
		 FROM skill_revisions
		 WHERE skill_id = ?
		 ORDER BY id DESC
		 LIMIT ?`,
		[input.skill_id, input.limit],
	)
}

/** Read-only dump of the legacy SQLite document store (for migration). */
export async function dumpLegacyStore(): Promise<{
	skills: SkillRow[]
	files: SkillFileRow[]
	versions: SkillVersionRow[]
}> {
	await ensureSchema()
	const skills = await sqlRows<SkillRow>(
		`SELECT id, name, description, created_at, updated_at FROM skills ORDER BY id ASC`,
	)
	const files = await sqlRows<SkillFileRow>(
		`SELECT skill_id, path, content, updated_at FROM skill_files ORDER BY skill_id ASC, path ASC`,
	)
	const versions = await sqlRows<SkillVersionRow>(
		`SELECT id, skill_id, path, content, replaced_at FROM skill_versions ORDER BY id ASC`,
	)
	return { skills, files, versions }
}