Skip to content

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

Package listing

@kentcdodds/lineage

src/storage.ts

333 lines · 9.1 KB · TypeScript
import { packageStorage } from 'kody:runtime'
import type { BucketDelta, Commentary, FileKind, IngestedCommit, RepoSettings } from './types.ts'

type SqlBucket = {
	sql: (
		query: string,
		params?: Array<string | number | boolean | null>,
	) => Promise<{ rows: Array<Record<string, unknown>> }>
}

let schemaReady = false

export function bucket(): SqlBucket {
	return packageStorage() as unknown as SqlBucket
}

export async function ensureSchema(): Promise<void> {
	if (schemaReady) return
	const db = bucket()
	await db.sql(`
		CREATE TABLE IF NOT EXISTS settings (
			key TEXT PRIMARY KEY,
			value TEXT NOT NULL
		)
	`)
	await db.sql(`
		CREATE TABLE IF NOT EXISTS commits (
			sha TEXT PRIMARY KEY,
			committed_at TEXT NOT NULL,
			message TEXT NOT NULL,
			author_name TEXT NOT NULL,
			html_url TEXT NOT NULL,
			parent_sha TEXT,
			files_truncated INTEGER NOT NULL DEFAULT 0,
			skipped_files INTEGER NOT NULL DEFAULT 0,
			counted_files INTEGER NOT NULL DEFAULT 0,
			ingested_at TEXT NOT NULL
		)
	`)
	await db.sql(`
		CREATE TABLE IF NOT EXISTS commit_buckets (
			sha TEXT NOT NULL,
			language TEXT NOT NULL,
			kind TEXT NOT NULL,
			additions INTEGER NOT NULL,
			deletions INTEGER NOT NULL,
			PRIMARY KEY (sha, language, kind)
		)
	`)
	await db.sql(`
		CREATE TABLE IF NOT EXISTS commentaries (
			id TEXT PRIMARY KEY,
			sha TEXT NOT NULL,
			label TEXT NOT NULL,
			narrative TEXT NOT NULL,
			created_at TEXT NOT NULL
		)
	`)
	await db.sql(`
		CREATE TABLE IF NOT EXISTS generated_paths (
			path TEXT PRIMARY KEY,
			reason TEXT NOT NULL
		)
	`)
	await db.sql(
		`CREATE INDEX IF NOT EXISTS commits_committed_at ON commits (committed_at)`,
	)
	await db.sql(
		`CREATE INDEX IF NOT EXISTS commentaries_sha ON commentaries (sha)`,
	)
	schemaReady = true
}

export const DEFAULT_REPO: RepoSettings = {
	owner: 'kentcdodds',
	repo: 'kody',
	branch: 'main',
}

export async function getRepoSettings(): Promise<RepoSettings> {
	await ensureSchema()
	const raw = await getSetting('repo')
	if (!raw) return { ...DEFAULT_REPO }
	try {
		const parsed = JSON.parse(raw) as Partial<RepoSettings>
		return {
			owner: parsed.owner || DEFAULT_REPO.owner,
			repo: parsed.repo || DEFAULT_REPO.repo,
			branch: parsed.branch || DEFAULT_REPO.branch,
		}
	} catch {
		return { ...DEFAULT_REPO }
	}
}

export async function setRepoSettings(next: Partial<RepoSettings>): Promise<RepoSettings> {
	const current = await getRepoSettings()
	const merged = {
		owner: next.owner?.trim() || current.owner,
		repo: next.repo?.trim() || current.repo,
		branch: next.branch?.trim() || current.branch,
	}
	await setSetting('repo', JSON.stringify(merged))
	return merged
}

export async function getSetting(key: string): Promise<string | null> {
	await ensureSchema()
	const result = await bucket().sql(`SELECT value FROM settings WHERE key = ?`, [
		key,
	])
	const value = result.rows[0]?.value
	return typeof value === 'string' ? value : null
}

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

export async function listKnownGeneratedPaths(): Promise<Map<string, string>> {
	await ensureSchema()
	const result = await bucket().sql(
		`SELECT path, reason FROM generated_paths`,
	)
	const map = new Map<string, string>()
	for (const row of result.rows) {
		if (typeof row.path === 'string' && typeof row.reason === 'string') {
			map.set(row.path, row.reason)
		}
	}
	return map
}

export async function rememberGeneratedPath(path: string, reason: string): Promise<void> {
	await ensureSchema()
	await bucket().sql(
		`INSERT INTO generated_paths (path, reason) VALUES (?, ?)
		 ON CONFLICT(path) DO UPDATE SET reason = excluded.reason`,
		[path, reason],
	)
}

export async function hasCommit(sha: string): Promise<boolean> {
	await ensureSchema()
	const result = await bucket().sql(
		`SELECT counted_files, skipped_files FROM commits WHERE sha = ?`,
		[sha],
	)
	if (result.rows.length === 0) return false
	const counted = Number(result.rows[0]?.counted_files ?? 0)
	const skipped = Number(result.rows[0]?.skipped_files ?? 0)
	if (counted === 0 && skipped === 0) return false
	if (counted === 0) return true
	const buckets = await bucket().sql(
		`SELECT COUNT(*) AS n FROM commit_buckets WHERE sha = ?`,
		[sha],
	)
	return Number(buckets.rows[0]?.n ?? 0) > 0
}

export async function listStoredShas(): Promise<Set<string>> {
	await ensureSchema()
	const result = await bucket().sql(`SELECT sha FROM commits`)
	return new Set(
		result.rows
			.map((row) => row.sha)
			.filter((sha): sha is string => typeof sha === 'string'),
	)
}

export async function upsertCommit(
	commit: IngestedCommit,
	buckets: BucketDelta[],
): Promise<void> {
	await ensureSchema()
	const db = bucket()
	await db.sql(
		`INSERT INTO commits (
			sha, committed_at, message, author_name, html_url, parent_sha,
			files_truncated, skipped_files, counted_files, ingested_at
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON CONFLICT(sha) DO UPDATE SET
			committed_at = excluded.committed_at,
			message = excluded.message,
			author_name = excluded.author_name,
			html_url = excluded.html_url,
			parent_sha = excluded.parent_sha,
			files_truncated = excluded.files_truncated,
			skipped_files = excluded.skipped_files,
			counted_files = excluded.counted_files,
			ingested_at = excluded.ingested_at`,
		[
			commit.sha,
			commit.committedAt,
			commit.message,
			commit.authorName,
			commit.htmlUrl,
			commit.parentSha,
			commit.filesTruncated ? 1 : 0,
			commit.skippedFiles,
			commit.countedFiles,
			new Date().toISOString(),
		],
	)
	await db.sql(`DELETE FROM commit_buckets WHERE sha = ?`, [commit.sha])
	for (const item of buckets) {
		if (item.additions === 0 && item.deletions === 0) continue
		await db.sql(
			`INSERT INTO commit_buckets (sha, language, kind, additions, deletions)
			 VALUES (?, ?, ?, ?, ?)
			 ON CONFLICT(sha, language, kind) DO UPDATE SET
				additions = excluded.additions,
				deletions = excluded.deletions`,
			[commit.sha, item.language, item.kind, item.additions, item.deletions],
		)
	}
}

export async function loadCommitRows(beforeInclusive?: string | null) {
	await ensureSchema()
	const db = bucket()
	const commits = beforeInclusive
		? await db.sql(
				`SELECT sha, committed_at, message, html_url
				 FROM commits
				 WHERE committed_at <= ?
				 ORDER BY committed_at ASC, sha ASC`,
				[beforeInclusive],
			)
		: await db.sql(
				`SELECT sha, committed_at, message, html_url
				 FROM commits
				 ORDER BY committed_at ASC, sha ASC`,
			)
	const buckets = beforeInclusive
		? await db.sql(
				`SELECT b.sha, b.language, b.kind, b.additions, b.deletions
				 FROM commit_buckets b
				 JOIN commits c ON c.sha = b.sha
				 WHERE c.committed_at <= ?`,
				[beforeInclusive],
			)
		: await db.sql(
				`SELECT sha, language, kind, additions, deletions FROM commit_buckets`,
			)
	return { commits: commits.rows, buckets: buckets.rows }
}

export async function ingestSummary() {
	await ensureSchema()
	const count = await bucket().sql(`SELECT COUNT(*) AS n FROM commits`)
	const newest = await bucket().sql(
		`SELECT sha, committed_at FROM commits ORDER BY committed_at DESC, sha DESC LIMIT 1`,
	)
	const oldest = await bucket().sql(
		`SELECT sha, committed_at FROM commits ORDER BY committed_at ASC, sha ASC LIMIT 1`,
	)
	return {
		storedCommits: Number(count.rows[0]?.n ?? 0),
		newest: asEdge(newest.rows[0]),
		oldest: asEdge(oldest.rows[0]),
	}
}

export async function listCommentaries(): Promise<Commentary[]> {
	await ensureSchema()
	const result = await bucket().sql(
		`SELECT id, sha, label, narrative, created_at
		 FROM commentaries
		 ORDER BY created_at ASC`,
	)
	return result.rows.map((row) => ({
		id: String(row.id),
		sha: String(row.sha),
		label: String(row.label),
		narrative: String(row.narrative),
		createdAt: String(row.created_at),
	}))
}

export async function upsertCommentary(input: {
	id?: string
	sha: string
	label: string
	narrative: string
}): Promise<Commentary> {
	await ensureSchema()
	const id = input.id?.trim() || crypto.randomUUID()
	const createdAt = new Date().toISOString()
	await bucket().sql(
		`INSERT INTO commentaries (id, sha, label, narrative, created_at)
		 VALUES (?, ?, ?, ?, ?)
		 ON CONFLICT(id) DO UPDATE SET
			sha = excluded.sha,
			label = excluded.label,
			narrative = excluded.narrative`,
		[id, input.sha.trim(), input.label.trim(), input.narrative.trim(), createdAt],
	)
	return {
		id,
		sha: input.sha.trim(),
		label: input.label.trim(),
		narrative: input.narrative.trim(),
		createdAt,
	}
}

export async function deleteCommentary(id: string): Promise<boolean> {
	await ensureSchema()
	const existing = await bucket().sql(
		`SELECT id FROM commentaries WHERE id = ?`,
		[id],
	)
	if (existing.rows.length === 0) return false
	await bucket().sql(`DELETE FROM commentaries WHERE id = ?`, [id])
	return true
}

function asEdge(row?: Record<string, unknown>) {
	if (!row || typeof row.sha !== 'string' || typeof row.committed_at !== 'string') {
		return null
	}
	return { sha: row.sha, at: row.committed_at }
}

export function isFileKind(value: string): value is FileKind {
	return value === 'source' || value === 'tests' || value === 'docs'
}