Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .codex/steering/repository-steering.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Core priorities:
- no server-side repository data storage
- stable shared TypeScript contracts
- stable typed contracts between modules
- config/constant driven behavior instead of repeated magic strings or values
- maintainable local workflows

## Active Boundaries
Expand Down Expand Up @@ -41,3 +42,11 @@ Safe default changes:
- endpoint hardening and validation
- focused test additions
- typing improvements

## Configuration Standards

- Prefer named constants or typed configuration objects for route paths, storage keys, cookie names, rate limits, TTLs, security headers, score weights, category rules, labels, and user-facing status text.
- Keep constants private to their owning module when only one module uses them.
- Promote constants to shared typed config modules when values are reused across modules, packages, workers, or tests.
- Avoid duplicating string literals across runtime code and tests; tests should import stable constants or assert behavior through public APIs when possible.
- Treat scoring and category changes as config changes first, then algorithm changes only when config cannot express the behavior clearly.
40 changes: 38 additions & 2 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,41 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Run quality gate
run: npm run check
- name: Check scripts
run: npm run check:scripts

- name: Check pre-MVP syntax
run: npm run check:pre-mvp

- name: Check analysis review fixture
run: npm run check:analysis

- name: Check docs
run: npm run check:docs

- name: Check workspace
run: npm run check:workspace

- name: Test packages with coverage
run: npm run test:coverage

- name: Test web app
run: npm run check:web-tests

- name: Test Worker
run: npm run check:worker-tests

- name: Lint
run: npm run lint

- name: Check Astro formatting
run: npm run format:astro:check

- name: Typecheck
run: npm run typecheck

- name: Build
run: npm run build

- name: Smoke test web build
run: npm run check:web-smoke
10 changes: 10 additions & 0 deletions apps/web/src/lib/analysis-result-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { RepositoryAnalysis } from "@forage/shared";
import { db } from "./db-schema";

export async function saveAnalysisResults(results: RepositoryAnalysis[]) {
await db.analysisResults.bulkPut(results);
}

export async function getAllAnalysisResults() {
return db.analysisResults.toArray();
}
42 changes: 42 additions & 0 deletions apps/web/src/lib/db-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ForageRepository, ImportEvent, RepositoryAnalysis } from "@forage/shared";
import Dexie, { type EntityTable } from "dexie";

export const localLibraryProfileKey = "local-library-profile";
export const localOperationLockKey = "local-operation-lock";

export type LocalOperationName = "import" | "reset";

export interface LocalLibraryProfile {
id: typeof localLibraryProfileKey;
github_login: string | null;
github_user_id: number | null;
repository_count: number;
updated_at: string;
}

export interface LocalOperationLock {
id: typeof localOperationLockKey;
owner_id: string;
operation: LocalOperationName;
acquired_at: string;
heartbeat_at: string;
expires_at: string;
}

type MetadataRecord = LocalLibraryProfile | LocalOperationLock;

interface ForageDatabase extends Dexie {
repositories: EntityTable<ForageRepository, "github_id">;
importEvents: EntityTable<ImportEvent, "id">;
metadata: EntityTable<MetadataRecord, "id">;
analysisResults: EntityTable<RepositoryAnalysis, "repository_id">;
}

export const db = new Dexie("forage") as ForageDatabase;

db.version(3).stores({
repositories: "github_id, &full_name, primary_language, starred_at",
importEvents: "id",
metadata: "id",
analysisResults: "repository_id, &repository_full_name, analysis_version",
});
212 changes: 16 additions & 196 deletions apps/web/src/lib/db.ts
Original file line number Diff line number Diff line change
@@ -1,196 +1,16 @@
import type { ForageRepository, ImportEvent, RepositoryAnalysis } from "@forage/shared";
import Dexie, { type EntityTable } from "dexie";

const localLibraryProfileKey = "local-library-profile";
const localOperationLockKey = "local-operation-lock";

export type LocalOperationName = "import" | "reset";

export interface LocalLibraryProfile {
id: typeof localLibraryProfileKey;
github_login: string | null;
github_user_id: number | null;
repository_count: number;
updated_at: string;
}

export interface LocalOperationLock {
id: typeof localOperationLockKey;
owner_id: string;
operation: LocalOperationName;
acquired_at: string;
heartbeat_at: string;
expires_at: string;
}

type MetadataRecord = LocalLibraryProfile | LocalOperationLock;

interface ForageDatabase extends Dexie {
repositories: EntityTable<ForageRepository, "github_id">;
importEvents: EntityTable<ImportEvent, "id">;
metadata: EntityTable<MetadataRecord, "id">;
analysisResults: EntityTable<RepositoryAnalysis, "repository_id">;
}

const db = new Dexie("forage") as ForageDatabase;

db.version(3).stores({
repositories: "github_id, &full_name, primary_language, starred_at",
importEvents: "id",
metadata: "id",
analysisResults: "repository_id, &repository_full_name, analysis_version",
});

export async function saveRepositories(repositories: ForageRepository[]) {
await db.repositories.bulkPut(repositories);
}

export async function getAllRepositories() {
return db.repositories.toArray();
}

export async function saveImportEvent(event: ImportEvent) {
await db.importEvents.put(event);
}

export async function getImportEvents() {
const events = await db.importEvents.toArray();
return events.sort((left, right) => right.started_at.localeCompare(left.started_at));
}

export async function saveAnalysisResults(results: RepositoryAnalysis[]) {
await db.analysisResults.bulkPut(results);
}

export async function getAllAnalysisResults() {
return db.analysisResults.toArray();
}

export async function reconcileImportedRepositories(importedGithubIds: Iterable<number>) {
const retainedIds = new Set(importedGithubIds);

await db.transaction("rw", db.repositories, db.analysisResults, async () => {
const [repositories, analysisResults] = await Promise.all([
db.repositories.toArray(),
db.analysisResults.toArray(),
]);
const staleRepositoryIds = repositories
.map((repository) => repository.github_id)
.filter((githubId) => !retainedIds.has(githubId));
const staleAnalysisIds = analysisResults
.map((analysis) => analysis.repository_id)
.filter((repositoryId) => !retainedIds.has(repositoryId));

await Promise.all([
db.repositories.bulkDelete(staleRepositoryIds),
db.analysisResults.bulkDelete(staleAnalysisIds),
]);
});
}

export async function saveLocalLibraryProfile(profile: Omit<LocalLibraryProfile, "id">) {
await db.metadata.put({ ...profile, id: localLibraryProfileKey });
}

export async function getLocalLibraryProfile() {
return (
((await db.metadata.get(localLibraryProfileKey)) as LocalLibraryProfile | undefined) ?? null
);
}

export async function acquireLocalOperationLock(
operation: LocalOperationName,
{
ttlMs = 15 * 60 * 1000,
now = new Date(),
ownerId = crypto.randomUUID(),
}: {
ttlMs?: number;
now?: Date;
ownerId?: string;
} = {},
) {
const nowMs = now.getTime();
const lock: LocalOperationLock = {
id: localOperationLockKey,
owner_id: ownerId,
operation,
acquired_at: now.toISOString(),
heartbeat_at: now.toISOString(),
expires_at: new Date(nowMs + ttlMs).toISOString(),
};

const blockingLock = await db.transaction("rw", db.metadata, async () => {
const existing = (await db.metadata.get(localOperationLockKey)) as
| LocalOperationLock
| undefined;
if (existing && Date.parse(existing.expires_at) > nowMs) return existing;

await db.metadata.put(lock);
return null;
});

return blockingLock
? {
acquired: false as const,
lock: blockingLock,
}
: {
acquired: true as const,
lock,
};
}

export async function refreshLocalOperationLock(
ownerId: string,
{
ttlMs = 15 * 60 * 1000,
now = new Date(),
}: {
ttlMs?: number;
now?: Date;
} = {},
) {
await db.transaction("rw", db.metadata, async () => {
const existing = (await db.metadata.get(localOperationLockKey)) as
| LocalOperationLock
| undefined;
if (!existing || existing.owner_id !== ownerId) return;

const refreshedLock: LocalOperationLock = {
...existing,
heartbeat_at: now.toISOString(),
expires_at: new Date(now.getTime() + ttlMs).toISOString(),
};
await db.metadata.put(refreshedLock);
});
}

export async function releaseLocalOperationLock(ownerId: string) {
await db.transaction("rw", db.metadata, async () => {
const existing = (await db.metadata.get(localOperationLockKey)) as
| LocalOperationLock
| undefined;
if (existing?.owner_id === ownerId) {
await db.metadata.delete(localOperationLockKey);
}
});
}

export async function resetLocalData() {
await db.transaction(
"rw",
db.repositories,
db.importEvents,
db.analysisResults,
db.metadata,
async () => {
await Promise.all([
db.repositories.clear(),
db.importEvents.clear(),
db.analysisResults.clear(),
db.metadata.clear(),
]);
},
);
}
export { getAllAnalysisResults, saveAnalysisResults } from "./analysis-result-store";
export type { LocalLibraryProfile, LocalOperationLock, LocalOperationName } from "./db-schema";
export { getImportEvents, saveImportEvent } from "./import-event-store";
export { resetLocalData } from "./local-data-store";
export { getLocalLibraryProfile, saveLocalLibraryProfile } from "./local-profile-store";
export {
acquireLocalOperationLock,
defaultLocalOperationLockTtlMs,
refreshLocalOperationLock,
releaseLocalOperationLock,
} from "./operation-lock-store";
export {
getAllRepositories,
reconcileImportedRepositories,
saveRepositories,
} from "./repository-store";
11 changes: 11 additions & 0 deletions apps/web/src/lib/import-event-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ImportEvent } from "@forage/shared";
import { db } from "./db-schema";

export async function saveImportEvent(event: ImportEvent) {
await db.importEvents.put(event);
}

export async function getImportEvents() {
const events = await db.importEvents.toArray();
return events.sort((left, right) => right.started_at.localeCompare(left.started_at));
}
19 changes: 19 additions & 0 deletions apps/web/src/lib/local-data-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { db } from "./db-schema";

export async function resetLocalData() {
await db.transaction(
"rw",
db.repositories,
db.importEvents,
db.analysisResults,
db.metadata,
async () => {
await Promise.all([
db.repositories.clear(),
db.importEvents.clear(),
db.analysisResults.clear(),
db.metadata.clear(),
]);
},
);
}
11 changes: 11 additions & 0 deletions apps/web/src/lib/local-profile-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { db, type LocalLibraryProfile, localLibraryProfileKey } from "./db-schema";

export async function saveLocalLibraryProfile(profile: Omit<LocalLibraryProfile, "id">) {
await db.metadata.put({ ...profile, id: localLibraryProfileKey });
}

export async function getLocalLibraryProfile() {
return (
((await db.metadata.get(localLibraryProfileKey)) as LocalLibraryProfile | undefined) ?? null
);
}
Loading
Loading