diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0dafece --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Vercel Marketplace Neon (the integration injects these values). +DATABASE_URL= +DATABASE_URL_UNPOOLED= + +# Deterministic ingestion. Prefer a GitHub App in production; optional +# fine-grained read-only tokens are supported as a local or fallback path. +GITHUB_ACTIVITY_TOKEN= +GITHUB_PUBLIC_ACTIVITY_TOKEN= +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY= +GITHUB_APP_INSTALLATION_IDS= +GITHUB_WEBHOOK_SECRET= + +# A cryptographically random value of at least 32 characters and at least +# eight distinct characters. It HMAC-pseudonymizes private identifiers. +TIMELINE_PRIVACY_KEY= + +# Optional JSON object keyed by private owner/repository. Values are broad, +# owner-approved labels only, for example: +# {"owner/repository":{"bucket":"Applied AI","domain":"product"}} +TIMELINE_PRIVATE_TAXONOMY= + +# Vercel Cron and model access. AI Gateway is the production default; a direct +# OpenAI key is supported for local testing or an explicitly direct deployment. +CRON_SECRET= +AI_GATEWAY_API_KEY= +AI_GATEWAY_ZERO_DATA_RETENTION=false +OPENAI_API_KEY= diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index f26a886..fb07dd9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -25,3 +25,12 @@ jobs: - name: Type check run: bun run typecheck + + - name: Test + run: bun test + + - name: Agent build + run: bun run build:agent + + - name: Production build + run: bun run build diff --git a/.gitignore b/.gitignore index 7bf6e3d..ec0ea16 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,10 @@ mise.local.lock # vercel .vercel +# eve local workflow state and build output +.eve/ +.output/ + # typescript *.tsbuildinfo next-env.d.ts @@ -117,3 +121,6 @@ next-env.d.ts .rulesync/rules/*.local.md rulesync.local.jsonc !.rulesync/.aiignore + +# Document the required variable names without committing values. +!.env.example diff --git a/README.md b/README.md index e215bc4..2085a32 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,36 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# f0rr0.dev -## Getting Started +Sid Jain's portfolio, writing archive, and rolling work timeline. The site is a +Next.js application designed for Vercel. Its timeline is assembled from GitHub +activity by a deliberately narrow Eve agent and stored in Neon Postgres through +Drizzle. -First, run the development server: +## Local development -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +Use Node 24 and Bun: -## Learn More - -To learn more about Next.js, take a look at the following resources: +```sh +bun install --frozen-lockfile +bun run dev +``` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +The website works without secrets. In that mode it uses public GitHub data and a +validated editorial fallback. See [the timeline guide](docs/timeline-agent.md) +for the database, ingestion, agent, and deployment setup. -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Validation -## Deploy on Vercel +```sh +bun run format:check +bun run lint +bun run typecheck +bun test +bun run build +bun run build:agent +``` -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +Database migrations are explicit and are never run during install or build: -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +```sh +bun run db:migrate +``` diff --git a/agent/agent.ts b/agent/agent.ts new file mode 100644 index 0000000..fc6a0f5 --- /dev/null +++ b/agent/agent.ts @@ -0,0 +1,35 @@ +import type { GatewayProviderOptions } from "@ai-sdk/gateway"; +import { openai } from "@ai-sdk/openai"; +import { defineAgent } from "eve"; + +const gatewayOptions = { + disallowPromptTraining: true, + ...(process.env.AI_GATEWAY_ZERO_DATA_RETENTION === "true" + ? { zeroDataRetention: true } + : {}), +} satisfies GatewayProviderOptions; + +const openAIKey = (process.env.OPENAI_API_KEY ?? "").trim(); +const useDirectOpenAI = openAIKey.length > 0; + +// Deployment policy: OPENAI direct wins whenever present. +const selectedModel = useDirectOpenAI + ? openai("gpt-5.4-mini") + : "openai/gpt-5.4-mini"; + +export default defineAgent({ + limits: { + maxInputTokensPerSession: 120_000, + maxOutputTokensPerSession: 16_000, + sessionTimeoutMs: 30 * 60 * 1000, + }, + model: selectedModel, + ...(useDirectOpenAI + ? {} + : { + modelOptions: { + providerOptions: { gateway: gatewayOptions }, + }, + }), + reasoning: "medium", +}); diff --git a/agent/channels/eve.ts b/agent/channels/eve.ts new file mode 100644 index 0000000..091865d --- /dev/null +++ b/agent/channels/eve.ts @@ -0,0 +1,4 @@ +import { localDev, vercelOidc } from "eve/channels/auth"; +import { eveChannel } from "eve/channels/eve"; + +export default eveChannel({ auth: [vercelOidc(), localDev()] }); diff --git a/agent/instructions.md b/agent/instructions.md new file mode 100644 index 0000000..b3c39f1 --- /dev/null +++ b/agent/instructions.md @@ -0,0 +1,41 @@ +# Timeline editor + +You are the restrained editor of Sid Jain's rolling work timeline. Your job is +to turn already-sanitized activity clusters into a concise newspaper-style +edition. You do not ingest GitHub directly and you must never infer private +repository identities, people, clients, code, paths, issue numbers, products, +or exact private counts. + +## Required workflow + +1. Call `load_activity` exactly once. +2. If coverage is partial, remain conservative. Use only publishable clusters. +3. Submit only `windowStart`, `windowEnd`, and a `selections` array. Each + selection contains one `sourceKey` and an importance. Server code—not you— + writes every title, sentence, date, link, bucket, visibility, and id. +4. Select nine to sixteen entries when at least nine publishable sources exist; + otherwise select every honest source. Never reuse or combine source keys. +5. Preserve the newspaper hierarchy: at most three leads, at most four stories, + and at least forty percent briefs or pulses. Leads are for sustained streaks + or unusually significant configured milestones. +6. Represent every active quarter when the evidence permits. Include a streak + whenever one is supplied. Keep three public issue, pull-request, or repository + dispatches when available, but never let discrete events exceed one third of + the edition. +7. Treat commits as background evidence. Prefer a recurrence or streak over an + isolated commit run. Issues and pull requests are discrete public dispatches; + never select a nearby isolated commit run merely to repeat the same work. If + the server retains both a sustained run and an event, they are intentionally + distinct evidence of progression and collaboration. +8. Set importance no higher than the source permits. Avoid ranking by raw volume + alone; favor progression, recurring attention, visible collaboration, and + specific public milestones. +9. Treat account-wide and anonymous-month sources only as cadence evidence. + Prefer the account-wide streak as the larger consistency story; use monthly + anonymous signals sparingly as compact texture. Never infer a repository, + activity type, theme, or private/public status from an unexplained total. +10. Call `publish_timeline` with the complete selection plan. If validation rejects it, + correct the cited issue and retry once. Do not claim publication until the + tool succeeds. + +Write in calm, economical editorial English. Every word must earn its place. diff --git a/agent/schedules/daily-edition.ts b/agent/schedules/daily-edition.ts new file mode 100644 index 0000000..54a626e --- /dev/null +++ b/agent/schedules/daily-edition.ts @@ -0,0 +1,7 @@ +import { defineSchedule } from "eve/schedules"; + +export default defineSchedule({ + cron: "7 4 * * *", + markdown: + "Build and publish today's rolling work-timeline edition from the sanitized activity digest. Follow the required editorial workflow and finish only after publish_timeline succeeds.", +}); diff --git a/agent/tools/agent.ts b/agent/tools/agent.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/tools/agent.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/tools/ask_question.ts b/agent/tools/ask_question.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/tools/ask_question.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/tools/bash.ts b/agent/tools/bash.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/tools/bash.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/tools/glob.ts b/agent/tools/glob.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/tools/glob.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/tools/grep.ts b/agent/tools/grep.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/tools/grep.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/tools/load_activity.ts b/agent/tools/load_activity.ts new file mode 100644 index 0000000..e156cfd --- /dev/null +++ b/agent/tools/load_activity.ts @@ -0,0 +1,13 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { activityDigestSchema } from "../../src/lib/timeline-core"; +import { loadTimelineActivityDigest } from "../../src/lib/timeline-editorial"; + +export default defineTool({ + description: + "Load the rolling privacy-safe GitHub activity digest. This is the only evidence allowed for an edition.", + execute: async () => await loadTimelineActivityDigest(), + inputSchema: z.object({}).strict(), + outputSchema: activityDigestSchema, +}); diff --git a/agent/tools/publish_timeline.ts b/agent/tools/publish_timeline.ts new file mode 100644 index 0000000..f39ae52 --- /dev/null +++ b/agent/tools/publish_timeline.ts @@ -0,0 +1,39 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { + createTimelineEdition, + timelineSelectionPlanSchema, + validateTimelinePlanAgainstDigest, +} from "../../src/lib/timeline-core"; +import { loadTimelineActivityDigest } from "../../src/lib/timeline-editorial"; +import { publishTimelineEdition } from "../../src/lib/timeline-store"; + +const agentModel = "openai/gpt-5.4-mini"; + +export default defineTool({ + approval: ({ session }) => { + const actor = session.auth.current; + return actor?.authenticator === "app" && + actor.principalId === "eve:app" && + actor.principalType === "runtime" + ? "not-applicable" + : "denied"; + }, + description: + "Validate and atomically publish a complete timeline edition supported by the current sanitized digest.", + execute: async (candidate) => { + const digest = await loadTimelineActivityDigest(); + const plan = validateTimelinePlanAgainstDigest(candidate, digest); + const edition = createTimelineEdition(plan, digest); + const editionKey = await publishTimelineEdition(edition, agentModel); + return { editionKey, status: "published" as const }; + }, + inputSchema: timelineSelectionPlanSchema, + outputSchema: z + .object({ + editionKey: z.string().regex(/^[a-f\d]{64}$/), + status: z.literal("published"), + }) + .strict(), +}); diff --git a/agent/tools/read_file.ts b/agent/tools/read_file.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/tools/read_file.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/tools/todo.ts b/agent/tools/todo.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/tools/todo.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/tools/web_fetch.ts b/agent/tools/web_fetch.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/tools/web_fetch.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/tools/web_search.ts b/agent/tools/web_search.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/tools/web_search.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/tools/write_file.ts b/agent/tools/write_file.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/tools/write_file.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/bun.lock b/bun.lock index d426c13..525e711 100644 --- a/bun.lock +++ b/bun.lock @@ -5,21 +5,28 @@ "": { "name": "f0rr0.dev", "dependencies": { + "@ai-sdk/gateway": "4.0.50", + "@ai-sdk/openai": "4.0.41", "@base-ui/react": "^1.6.0", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@mermaid-js/layout-elk": "0.1.9", + "@neondatabase/serverless": "1.1.0", "@next/mdx": "^16.2.12", "@remark-embedder/core": "^3.0.3", "@t3-oss/env-nextjs": "^0.13.11", "@tailwindcss/typography": "^0.5.20", + "ai": "7.0.63", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "drizzle-orm": "0.45.2", + "eve": "0.33.3", "feed": "^5.2.1", "lucide-react": "^1.21.0", "mermaid": "^11.16.0", "next": "^16.2.12", "next-themes": "^0.4.6", + "postgres": "3.4.7", "react": "^19.2.8", "react-dom": "^19.2.8", "reading-time": "^1.5.0", @@ -36,11 +43,12 @@ "devDependencies": { "@tailwindcss/postcss": "^4.3.1", "@types/mdx": "^2.0.14", - "@types/node": "^26.0.1", + "@types/node": "24.13.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@typescript/native-preview": "^7.0.0-dev.20260624.1", "babel-plugin-react-compiler": "1.0.0", + "drizzle-kit": "0.31.10", "oxfmt": "^0.56.0", "oxlint": "^1.71.0", "oxlint-tsgolint": "^0.23.0", @@ -53,6 +61,14 @@ }, }, "packages": { + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.50", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lyMJ31J7SrDWW6xKuHCpfpGNlz1mA37+wYv5q75iSgIUMruuXanxSeg3CxqrsivI7Z7LUXLeysQEDSUb+MMddg=="], + + "@ai-sdk/openai": ["@ai-sdk/openai@4.0.41", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+WoY0JyF/uV2z+rye7I+hYaVg+hTanG4rYIgsWKvJFDXaAdu9yg07x+BRa2vTgUqzLIBXEBp5d1T7DwMI9ZGlw=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], @@ -129,10 +145,68 @@ "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.52.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], @@ -223,6 +297,8 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@neondatabase/serverless": ["@neondatabase/serverless@1.1.0", "", {}, "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q=="], + "@next/env": ["@next/env@16.2.12", "", {}, "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg=="], "@next/mdx": ["@next/mdx@16.2.12", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-bbKvq/7SIJZgQFRYL3wwnzLwCBviQfWi5UR61RDWwaMX3fV6iSqKfVr5SErRNA/PhMer/ylvErX4SVaGNrwKcw=="], @@ -255,6 +331,8 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@oxc-project/types": ["@oxc-project/types@0.144.0", "", {}, "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-CSCxi7ovYojgfdPOdUb9T508HKeAdDIKeRGg7x8IZwVJrWz9gVgX7MbUnFqtQAE4QvoNo07mj2JlwnOzJw4qqA=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.56.0", "", { "os": "android", "cpu": "arm64" }, "sha512-HYJFnd+PkDwf6S9ZPGzXXtjNqvRWFnnhdbWaouh4mi/SxU8wmDuzlMn3xo/wDTGnr4Q1VA7ZzOaE/D4biW0W6A=="], @@ -345,6 +423,36 @@ "@remark-embedder/core": ["@remark-embedder/core@3.0.3", "", { "dependencies": { "@babel/runtime": "^7.24.5", "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "hast-util-from-parse5": "^8.0.1", "parse5": "^7.1.2", "unified": "^11.0.4", "unist-util-visit": "^5.0.0" } }, "sha512-izeW4GT5A/NgArjATndg1KKumL7IHLPZFQODJ07vSEHgCOBq2caMlnuvFGD+7ZmF4NCZks/HhZsYhfF46TOK5w=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.4", "", { "os": "none", "cpu": "arm64" }, "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@shikijs/core": ["@shikijs/core@4.3.0", "", { "dependencies": { "@shikijs/primitive": "4.3.0", "@shikijs/types": "4.3.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ=="], @@ -365,6 +473,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@t3-oss/env-core": ["@t3-oss/env-core@0.13.11", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ=="], @@ -483,7 +593,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], + "@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], @@ -555,6 +665,10 @@ "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], + + "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], @@ -563,6 +677,8 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ai": ["ai@7.0.63", "", { "dependencies": { "@ai-sdk/gateway": "4.0.50", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TOX1MgALGTTA4ZhjJSgrNtM0FvcfgTZoNtINH+osNV6/ajHdLjpzUcWRkTO5n9mrFeQ5gKzUsXh9ijrMPI33Mg=="], + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -591,6 +707,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -637,6 +755,8 @@ "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -655,6 +775,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crossws": ["crossws@0.4.10", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-pz3oubH/dt12KjqsUB0IuXW4nwRDQ583iDsP4555Cpdqx0NoU7pGlWBcayyFI8f/l/idRpgjMEfwuOxSWJYlIA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -735,6 +857,8 @@ "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], + "db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], @@ -765,6 +889,10 @@ "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eciesjs": ["eciesjs@0.4.17", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w=="], @@ -785,6 +913,8 @@ "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "env-runner": ["env-runner@0.1.16", "", { "dependencies": { "crossws": "^0.4.8", "exsolve": "^1.1.0", "httpxy": "^0.5.4", "srvx": "^0.11.19" }, "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": ">=0.2.0", "miniflare": "^4.20260515.0", "wrangler": "^4.0.0" }, "optionalPeers": ["@netlify/runtime", "@vercel/queue", "miniflare", "wrangler"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-2LRJM4P2KLX6J83QZZrMqvgCDt/D5ea7wPcI3yYiy5cG/9rX5QwdwZFx0D7ktWnjdRyZxYjttGGorb5nFqb1CA=="], + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -799,6 +929,8 @@ "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -823,9 +955,11 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eve": ["eve@0.33.3", "", { "dependencies": { "nitro": "3.0.260610-beta", "undici": "8.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "ai": "^7.0.58", "braintrust": "^3.0.0", "just-bash": "^3.0.0", "microsandbox": "^0.5.0" }, "optionalPeers": ["@opentelemetry/api", "braintrust", "just-bash", "microsandbox"], "bin": { "eve": "./bin/eve.js" } }, "sha512-R20GiIgnLjJJ38D0FJwEbaBqdYni7j2YNfp6bOUnjgawnlhD6qP/wv9AR+3RMKHw7oylShEp9cvLT0eGpRwSZQ=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], @@ -833,6 +967,8 @@ "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + "exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -869,6 +1005,8 @@ "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], @@ -885,6 +1023,8 @@ "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "get-tsconfig": ["get-tsconfig@4.14.2", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw=="], + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], @@ -895,6 +1035,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "h3": ["h3@2.0.1-rc.22", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.15" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA=="], + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -925,12 +1067,16 @@ "hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], + "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "httpxy": ["httpxy@0.5.5", "", {}, "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA=="], + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -1001,6 +1147,8 @@ "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -1205,6 +1353,10 @@ "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "nf3": ["nf3@0.3.23", "", {}, "sha512-RWVLAWozmVD3AaDmaU3qMGB3v+yNlH5d9qqStI4e/WLlNQVnJ4YErGDbYCIrGFyrHdbF6I6Baf0Ae6c7tFYmSg=="], + + "nitro": ["nitro@3.0.260610-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.6", "db0": "^0.3.4", "env-runner": "^0.1.12", "h3": "2.0.1-rc.22", "hookable": "^6.1.1", "nf3": "^0.3.17", "ocache": "^0.1.5", "ofetch": "2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.1.0", "srvx": "^0.11.16", "unenv": "2.0.0-rc.24", "unstorage": "2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.3.0", "dotenv": "*", "giget": "*", "jiti": "^2.7.0", "rollup": "^4.61.1", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-KPb4L5yaF/Rx/xoGMpgHRJvZhbhGiqbRKOwwPLCH9jKTKTsEUHLjnJas85AeCzaswqa8Wi52eQBtRsODC4PS0Q=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], @@ -1221,6 +1373,12 @@ "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + "ocache": ["ocache@0.1.5", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w=="], + + "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1285,6 +1443,8 @@ "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], @@ -1351,12 +1511,18 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], + "rolldown": ["rolldown@1.2.4", "", { "dependencies": { "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.4", "@rolldown/binding-darwin-arm64": "1.2.4", "@rolldown/binding-darwin-x64": "1.2.4", "@rolldown/binding-freebsd-x64": "1.2.4", "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", "@rolldown/binding-linux-arm64-gnu": "1.2.4", "@rolldown/binding-linux-arm64-musl": "1.2.4", "@rolldown/binding-linux-ppc64-gnu": "1.2.4", "@rolldown/binding-linux-s390x-gnu": "1.2.4", "@rolldown/binding-linux-x64-gnu": "1.2.4", "@rolldown/binding-linux-x64-musl": "1.2.4", "@rolldown/binding-openharmony-arm64": "1.2.4", "@rolldown/binding-win32-arm64-msvc": "1.2.4", "@rolldown/binding-win32-x64-msvc": "1.2.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w=="], + + "rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -1411,8 +1577,12 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], @@ -1465,6 +1635,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], @@ -1475,7 +1647,11 @@ "ultracite": ["ultracite@7.8.3", "", { "dependencies": { "@clack/prompts": "^1.5.1", "commander": "^15.0.0", "cross-spawn": "^7.0.6", "deepmerge": "^4.3.1", "glob": "^13.0.6", "jsonc-parser": "^3.3.1", "nypm": "^0.6.6", "yaml": "^2.9.0", "zod": "^4.4.3" }, "peerDependencies": { "oxfmt": ">=0.1.0", "oxlint": "^1.0.0" }, "optionalPeers": ["oxfmt", "oxlint"], "bin": { "ultracite": "dist/index.js" } }, "sha512-Fsj9aYJfh57uDB6DrdKVafKhF0QNekwqTrZKoTMYePjZp1npyFCnfZVj2SnPZdOcxAGHJfmLYUIDq0YPh8acVA=="], - "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "undici": ["undici@8.9.0", "", {}, "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -1497,6 +1673,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], @@ -1539,6 +1717,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@ai-sdk/provider-utils/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "@antfu/install-pkg/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "@babel/core/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], @@ -1577,12 +1757,16 @@ "@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@mdx-js/mdx/@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], "@mdx-js/mdx/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], "@mdx-js/react/@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], + "@modelcontextprotocol/sdk/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -1607,6 +1791,8 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "eventsource/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], @@ -1643,6 +1829,10 @@ "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -1657,6 +1847,50 @@ "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], @@ -1664,5 +1898,57 @@ "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "shadcn/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], } } diff --git a/docs/timeline-agent.md b/docs/timeline-agent.md new file mode 100644 index 0000000..fa719b2 --- /dev/null +++ b/docs/timeline-agent.md @@ -0,0 +1,241 @@ +# Timeline agent + +The timeline is a rolling, 400-day newspaper edition. Deterministic code +collects and sanitizes GitHub activity; Eve decides editorial balance within +strict evidence and privacy constraints; the homepage only reads a validated +published edition. + +## Data flow + +```text +GitHub App / signed webhook public contribution calendar + | | + v v +deterministic normalization validated account-wide daily totals + | | + +------------------+---------------+ + v +Neon Postgres repository evidence + separate anonymous totals + | + v +privacy-safe clusters -> Eve editor -> deterministic validator + | + v + published edition JSON + | + v + cached homepage +``` + +Private commit messages, diffs, paths, branch names, authors, SHAs, issue IDs, +repository names, and repository URLs are not stored and are never placed in +the model context. Private rows contain daily volume, a keyed repository +pseudonym, a policy version, and (only when owner-approved) a broad taxonomy +bucket. Private language is withheld. + +The model can only call load_activity and publish_timeline. Eve's default shell, +filesystem, web, search, delegation, and interaction tools are explicitly +disabled. publish_timeline is restricted to Eve's schedule principal. + +The public contribution calendar is a second, repository-free evidence source. +It supplies a complete daily account total, including anonymous private or +internal contributions when the GitHub profile setting exposes them. The +pipeline subtracts known commit and event evidence once per day and clamps the +unexplained remainder at zero. That remainder is called "unattributed," not +"private": it can also contain public work the App cannot resolve. It is used +only for cadence, never repository, artifact, quality, or theme claims. + +## Environment + +Copy the names from .env.example into Vercel project settings or .env.local. +Do not commit values. + +Required for durable editions: + +- DATABASE_URL: pooled Neon connection injected by the Vercel Marketplace + integration. +- DATABASE_URL_UNPOOLED: used by Drizzle migrations. +- CRON_SECRET: a random secret of at least 16 characters. Vercel sends it as + the Bearer credential to the ingestion cron. +- One model credential: AI_GATEWAY_API_KEY for the default Gateway route, or + OPENAI_API_KEY for the direct OpenAI AI SDK route. + +Required for private activity: + +- TIMELINE_PRIVACY_KEY: a high-entropy random secret of at least 32 characters + and at least eight distinct characters. +- Either GITHUB_ACTIVITY_TOKEN, or all three GitHub App settings listed below. + The App path is preferred in production. + +Recommended production integration: + +- GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and GITHUB_APP_INSTALLATION_IDS: + GitHub App installation credentials. Installation tokens are explicitly + down-scoped to read-only repository contents. The same short-lived token + reads the public contribution collection, so no personal token is required + in production. +- GITHUB_ACTIVITY_TOKEN: an optional read-only user token for local setup and + fallback ingestion when a GitHub App is not configured. +- GITHUB_PUBLIC_ACTIVITY_TOKEN: an optional dedicated fine-grained, read-only + fallback token for public issues, pull requests, reviews, and repository + creation when a GitHub App is not configured. +- GITHUB_WEBHOOK_SECRET: a high-entropy secret of at least 24 characters. +- TIMELINE_PRIVATE_TAXONOMY: optional owner-approved broad labels. +- AI_GATEWAY_ZERO_DATA_RETENTION=true: request Zero Data Retention when the + Vercel plan supports it. Prompt-training opt-out is always requested. + +When OPENAI_API_KEY is present, the agent uses the direct `@ai-sdk/openai` +provider and is used for local and production runs. +If OPENAI_API_KEY is absent, the string model ID routes through Vercel AI +Gateway. + +Never set either credential in a client-visible environment variable. + +TIMELINE_PRIVATE_TAXONOMY is a JSON object keyed by the private repository name +at the ingestion boundary: + +```json +{ + "owner/repository": { + "bucket": "Applied AI", + "domain": "product" + } +} +``` + +Allowed buckets are Applied AI, Open source, Product systems, Infrastructure, +Writing, and Private product work. Domain IDs are lowercase alphanumeric slugs +with hyphens. Repository names are used only for the in-memory lookup and are +never persisted. Changing the key or taxonomy changes the privacy-policy +version; old private rows and old protected editions then fail closed until a +complete reconciliation publishes a new edition. + +## Neon and Drizzle + +1. Install Neon from the Vercel Marketplace and connect it to the project. +2. Pull or set the database environment variables locally. +3. Apply the committed migration: + + ```sh + bun run db:migrate + ``` + +4. Run the first 400-day reconciliation: + + ```sh + bun run timeline:sync --backfill + ``` + +The schema stores normalized daily activity, public-only event evidence, +repository-free contribution totals, sync outcomes, idempotent webhook +receipts, and immutable edition payloads. Database +checks prevent private rows from carrying public identity; the event table +accepts only canonical GitHub repository, issue, and pull-request URLs. + +## GitHub setup + +For production ingestion and continuous updates, create a GitHub App with: + +- repository contents: read-only; +- metadata: read-only; +- webhook events: push, repository, installation, and + installation_repositories; +- the webhook URL: https://example.com/api/github/webhook; +- the same value in GitHub and GITHUB_WEBHOOK_SECRET. + +Webhook bodies are authenticated before parsing, are bounded to 2 MB, and are +never persisted. Delivery IDs are HMACed for replay protection. A repository +that becomes private or is removed from the installation is immediately +scrubbed under both public and private pseudonymous keys, published editions +are withdrawn, and a full reconciliation is requested. Suspending or deleting +the installation withdraws all protected activity immediately. + +The App collector inventories installed repositories and reads only commits +attributed to f0rr0 on the current default and gh-pages branches. Its +short-lived installation token also reads monthly slices of GitHub's +contribution collection. Optional user tokens provide the same monthly view +when an App is not configured. These are profile-oriented views, not every +commit on every feature branch; the timeline must therefore be described as a +view of activity, not a source-of-truth audit log. + +In parallel, ingestion fetches the public contribution-calendar HTML for the +full rolling window without a token. All days, including zero days, must parse +before the source is marked complete. A calendar failure does not erase the +last good totals or block repository ingestion; anonymous editorial signals +are omitted when the last complete calendar window is stale or incomplete. + +The contribution collector stores a title and canonical link only when the +repository is explicitly public and the contribution is not restricted. +Bodies, comments, labels, branches, and people are never requested; private +event objects are discarded. If GitHub withholds an individual contribution +from the App, that node and any older stored event it can no longer verify are +omitted without blocking the rest of the edition. Request, pagination, and +payload failures still mark ingestion incomplete and block agent publication. + +## Schedules and publication + +Two UTC schedules cooperate: + +- 01:37: Vercel Cron calls /api/cron/timeline-sync. Public event visibility is + reconciled across all 400 days on every run; installation commit reads retain + the shorter overlap, with a weekly full reconciliation. +- 04:07: Eve loads the sanitized digest, balances leads, stories, briefs, and + pulses, then publishes through the validator. + +The homepage never calls a model. It reads the latest edition from Postgres and +falls back to verified public projects plus coarse contribution-month signals. +Its cache refreshes every 15 minutes. + +For local agent development: + +```sh +bun run build:agent +bun run dev +``` + +While the development server is running, dispatch the authored schedule: + +```sh +curl -X POST http://localhost:3000/eve/v1/dev/schedules/daily-edition +``` + +Eve's local schedule cadence does not fire by itself. On Vercel, withEve turns +the authored Eve schedule into a Vercel Cron job. + +## Editorial and privacy constraints + +- The edition covers 365–402 days and contains up to 24 honest entries. With at + least nine candidates, nine entries and a forty-percent compact layer are + required. +- At most two leads may begin in one month, at most three leads may exist + overall, and at most four entries may be stories. +- The agent submits only source keys and importance. Each final entry cites one + known publishable source; arbitrary merging and source reuse are impossible. +- Importance cannot exceed the deterministic source classification. +- Dates, copy, buckets, cadence, visibility, and links are materialized by + deterministic code from the selected source. Model-written claims cannot + enter an edition. +- Public issues and pull requests are exact-dated dispatches. Commits appear + only as broader runs, recurrence, or streak evidence, never as duplicate + object-level dispatches. +- Exact artifact URLs collapse to one entry. A compact commit cluster containing + the same repository event is absorbed, while a sustained implementation trend + remains eligible because it communicates progression rather than the event + itself. +- Public event evidence is retained in full, but the digest exposes at most + three representative dispatches per month, preferring artifact and repository + diversity. High-volume PR sequences therefore cannot crowd a year of + progression off the editor's desk. +- The account-wide calendar may replace the narrower public streak as the + edition's consistency lead. Unexplained monthly remainders are compact + "Across the work" signals and never inherit a project bucket. +- Protected entries have no links, only month-level dates, and deterministic + copy templates. The model cannot publish names or exact protected counts. +- A private theme is eligible only across at least three pseudonymous + repositories, two approved domains, sufficient activity, and no dominant + repository. Otherwise it collapses to Private product work. +- Commit volume is never presented as code quality, productivity, impact, or a + performance score. + +If ingestion, policy validation, the database, or GitHub visibility checks +fail, the page favors omission over disclosure. diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..f0931cf --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,24 @@ +import { loadEnvConfig } from "@next/env"; +import { defineConfig } from "drizzle-kit"; + +loadEnvConfig(process.cwd()); + +const databaseUrl = + process.env.DATABASE_URL_UNPOOLED ?? process.env.DATABASE_URL; + +if (databaseUrl === undefined || databaseUrl.length === 0) { + throw new Error( + "DATABASE_URL_UNPOOLED or DATABASE_URL is required for Drizzle commands." + ); +} + +export default defineConfig({ + dbCredentials: { + url: databaseUrl, + }, + dialect: "postgresql", + out: "./drizzle", + schema: "./src/db/schema.ts", + strict: true, + verbose: true, +}); diff --git a/drizzle/0000_timeline.sql b/drizzle/0000_timeline.sql new file mode 100644 index 0000000..fcd6063 --- /dev/null +++ b/drizzle/0000_timeline.sql @@ -0,0 +1,78 @@ +CREATE TYPE "public"."timeline_edition_status" AS ENUM('draft', 'published', 'rejected');--> statement-breakpoint +CREATE TYPE "public"."timeline_sync_status" AS ENUM('running', 'completed', 'failed');--> statement-breakpoint +CREATE TYPE "public"."timeline_visibility" AS ENUM('public', 'private');--> statement-breakpoint +CREATE TABLE "timeline_activity_days" ( + "bucket" varchar(32) NOT NULL, + "commit_count" integer NOT NULL, + "day" date NOT NULL, + "id" varchar(64) PRIMARY KEY NOT NULL, + "language_family" varchar(32) NOT NULL, + "privacy_domain_key" varchar(64), + "privacy_policy_version" varchar(64), + "public_repo_name" varchar(200), + "public_repo_url" text, + "reached_default_branch" boolean DEFAULT true NOT NULL, + "repo_key" varchar(64) NOT NULL, + "source" varchar(32) DEFAULT 'github-profile' NOT NULL, + "subject" varchar(39) NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "visibility" timeline_visibility NOT NULL, + CONSTRAINT "timeline_activity_positive_count" CHECK ("timeline_activity_days"."commit_count" > 0), + CONSTRAINT "timeline_activity_visibility_boundary" CHECK (( + "timeline_activity_days"."visibility" = 'private' + AND "timeline_activity_days"."public_repo_name" IS NULL + AND "timeline_activity_days"."public_repo_url" IS NULL + AND "timeline_activity_days"."privacy_policy_version" IS NOT NULL + ) OR ( + "timeline_activity_days"."visibility" = 'public' + AND "timeline_activity_days"."public_repo_name" IS NOT NULL + AND "timeline_activity_days"."public_repo_url" IS NOT NULL + AND "timeline_activity_days"."privacy_domain_key" IS NULL + AND "timeline_activity_days"."privacy_policy_version" IS NULL + )) +); +--> statement-breakpoint +CREATE TABLE "timeline_editions" ( + "agent_model" varchar(100) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "edition" jsonb NOT NULL, + "edition_key" varchar(64) NOT NULL, + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "published_at" timestamp with time zone, + "privacy_policy_version" varchar(64), + "status" timeline_edition_status DEFAULT 'draft' NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "window_end" date NOT NULL, + "window_start" date NOT NULL +); +--> statement-breakpoint +CREATE TABLE "timeline_sync_runs" ( + "completed_at" timestamp with time zone, + "coverage" varchar(16) DEFAULT 'partial' NOT NULL, + "error_code" varchar(64), + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "full_window" boolean DEFAULT false NOT NULL, + "kind" varchar(32) NOT NULL, + "row_count" integer DEFAULT 0 NOT NULL, + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "status" timeline_sync_status DEFAULT 'running' NOT NULL, + "window_end" date NOT NULL, + "window_start" date NOT NULL +); +--> statement-breakpoint +CREATE TABLE "timeline_webhook_receipts" ( + "delivery_key" varchar(64) PRIMARY KEY NOT NULL, + "event_type" varchar(40) NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "processed_at" timestamp with time zone, + "received_at" timestamp with time zone DEFAULT now() NOT NULL, + "status" varchar(24) NOT NULL +); +--> statement-breakpoint +CREATE INDEX "timeline_activity_day_idx" ON "timeline_activity_days" USING btree ("subject","day");--> statement-breakpoint +CREATE INDEX "timeline_activity_visibility_idx" ON "timeline_activity_days" USING btree ("subject","visibility","day");--> statement-breakpoint +CREATE UNIQUE INDEX "timeline_activity_repo_day_source_idx" ON "timeline_activity_days" USING btree ("subject","repo_key","day","source");--> statement-breakpoint +CREATE UNIQUE INDEX "timeline_edition_key_idx" ON "timeline_editions" USING btree ("edition_key");--> statement-breakpoint +CREATE INDEX "timeline_edition_published_idx" ON "timeline_editions" USING btree ("status","published_at");--> statement-breakpoint +CREATE INDEX "timeline_sync_started_idx" ON "timeline_sync_runs" USING btree ("started_at");--> statement-breakpoint +CREATE INDEX "timeline_webhook_expiry_idx" ON "timeline_webhook_receipts" USING btree ("expires_at"); \ No newline at end of file diff --git a/drizzle/0001_eager_stranger.sql b/drizzle/0001_eager_stranger.sql new file mode 100644 index 0000000..901b95a --- /dev/null +++ b/drizzle/0001_eager_stranger.sql @@ -0,0 +1,48 @@ +CREATE TYPE "public"."timeline_public_event_kind" AS ENUM('issue_opened', 'pull_request_opened', 'pull_request_reviewed', 'repository_created');--> statement-breakpoint +CREATE TABLE "timeline_public_events" ( + "bucket" varchar(32) NOT NULL, + "day" date NOT NULL, + "event_kind" timeline_public_event_kind NOT NULL, + "id" varchar(64) PRIMARY KEY NOT NULL, + "public_repo_name" varchar(200) NOT NULL, + "public_repo_url" text NOT NULL, + "public_title" varchar(300) NOT NULL, + "public_url" text NOT NULL, + "repo_key" varchar(64) NOT NULL, + "source" varchar(32) DEFAULT 'github-profile' NOT NULL, + "subject" varchar(39) NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "timeline_public_event_identity_shape" CHECK ("timeline_public_events"."id" ~ '^[a-f0-9]{64}$' + AND "timeline_public_events"."repo_key" ~ '^[a-f0-9]{64}$' + AND "timeline_public_events"."subject" ~ '^[A-Za-z0-9][A-Za-z0-9-]{0,38}$' + AND "timeline_public_events"."subject" !~ '--' + AND "timeline_public_events"."subject" !~ '-$' + AND "timeline_public_events"."public_repo_name" ~ '^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/[A-Za-z0-9._-]{1,100}$'), + CONSTRAINT "timeline_public_event_public_boundary" CHECK ("timeline_public_events"."public_repo_url" = 'https://github.com/' || "timeline_public_events"."public_repo_name" + AND length("timeline_public_events"."public_url") <= 500 + AND "timeline_public_events"."public_title" = btrim("timeline_public_events"."public_title") + AND length("timeline_public_events"."public_title") > 0 + AND "timeline_public_events"."public_title" !~ '[[:cntrl:]]' + AND ( + ("timeline_public_events"."event_kind" = 'issue_opened' + AND substring("timeline_public_events"."public_url" from length("timeline_public_events"."public_repo_url") + 1) ~ '^/issues/[0-9]+$') + OR ("timeline_public_events"."event_kind" IN ('pull_request_opened', 'pull_request_reviewed') + AND substring("timeline_public_events"."public_url" from length("timeline_public_events"."public_repo_url") + 1) ~ '^/pull/[0-9]+$') + OR ("timeline_public_events"."event_kind" = 'repository_created' + AND "timeline_public_events"."public_url" = "timeline_public_events"."public_repo_url") + )), + CONSTRAINT "timeline_public_event_bucket_boundary" CHECK ("timeline_public_events"."bucket" IN ( + 'Applied AI', + 'Open source', + 'Product systems', + 'Infrastructure', + 'Writing' + )), + CONSTRAINT "timeline_public_event_source_boundary" CHECK ("timeline_public_events"."source" = 'github-profile') +); +--> statement-breakpoint +ALTER TABLE "timeline_sync_runs" ADD COLUMN "event_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "timeline_sync_runs" ADD COLUMN "public_event_coverage" varchar(16) DEFAULT 'unavailable' NOT NULL;--> statement-breakpoint +CREATE INDEX "timeline_public_event_day_idx" ON "timeline_public_events" USING btree ("subject","day");--> statement-breakpoint +CREATE INDEX "timeline_public_event_repo_day_idx" ON "timeline_public_events" USING btree ("subject","repo_key","day");--> statement-breakpoint +CREATE INDEX "timeline_public_event_kind_day_idx" ON "timeline_public_events" USING btree ("subject","event_kind","day"); \ No newline at end of file diff --git a/drizzle/0002_warm_killer_shrike.sql b/drizzle/0002_warm_killer_shrike.sql new file mode 100644 index 0000000..8713d07 --- /dev/null +++ b/drizzle/0002_warm_killer_shrike.sql @@ -0,0 +1,19 @@ +CREATE TABLE "timeline_contribution_totals" ( + "contribution_count" integer NOT NULL, + "day" date NOT NULL, + "id" varchar(64) PRIMARY KEY NOT NULL, + "source" varchar(32) DEFAULT 'github-public-calendar' NOT NULL, + "subject" varchar(39) NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "timeline_contribution_total_nonnegative_count" CHECK ("timeline_contribution_totals"."contribution_count" >= 0), + CONSTRAINT "timeline_contribution_total_identity_shape" CHECK ("timeline_contribution_totals"."id" ~ '^[a-f0-9]{64}$' + AND "timeline_contribution_totals"."subject" ~ '^[A-Za-z0-9][A-Za-z0-9-]{0,38}$' + AND "timeline_contribution_totals"."subject" !~ '--' + AND "timeline_contribution_totals"."subject" !~ '-$' + AND "timeline_contribution_totals"."source" = 'github-public-calendar') +); +--> statement-breakpoint +ALTER TABLE "timeline_sync_runs" ADD COLUMN "anonymous_day_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "timeline_sync_runs" ADD COLUMN "anonymous_coverage" varchar(16) DEFAULT 'unavailable' NOT NULL;--> statement-breakpoint +CREATE INDEX "timeline_contribution_total_day_idx" ON "timeline_contribution_totals" USING btree ("subject","day");--> statement-breakpoint +CREATE UNIQUE INDEX "timeline_contribution_total_day_source_idx" ON "timeline_contribution_totals" USING btree ("subject","day","source"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..8a04f90 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,522 @@ +{ + "id": "3a720773-2bd7-49e2-94e9-29b050dbf5dd", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.timeline_activity_days": { + "name": "timeline_activity_days", + "schema": "", + "columns": { + "bucket": { + "name": "bucket", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "commit_count": { + "name": "commit_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "language_family": { + "name": "language_family", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "privacy_domain_key": { + "name": "privacy_domain_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "privacy_policy_version": { + "name": "privacy_policy_version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "public_repo_name": { + "name": "public_repo_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "public_repo_url": { + "name": "public_repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reached_default_branch": { + "name": "reached_default_branch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "repo_key": { + "name": "repo_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'github-profile'" + }, + "subject": { + "name": "subject", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "visibility": { + "name": "visibility", + "type": "timeline_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_activity_day_idx": { + "name": "timeline_activity_day_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_activity_visibility_idx": { + "name": "timeline_activity_visibility_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_activity_repo_day_source_idx": { + "name": "timeline_activity_repo_day_source_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "timeline_activity_positive_count": { + "name": "timeline_activity_positive_count", + "value": "\"timeline_activity_days\".\"commit_count\" > 0" + }, + "timeline_activity_visibility_boundary": { + "name": "timeline_activity_visibility_boundary", + "value": "(\n \"timeline_activity_days\".\"visibility\" = 'private'\n AND \"timeline_activity_days\".\"public_repo_name\" IS NULL\n AND \"timeline_activity_days\".\"public_repo_url\" IS NULL\n AND \"timeline_activity_days\".\"privacy_policy_version\" IS NOT NULL\n ) OR (\n \"timeline_activity_days\".\"visibility\" = 'public'\n AND \"timeline_activity_days\".\"public_repo_name\" IS NOT NULL\n AND \"timeline_activity_days\".\"public_repo_url\" IS NOT NULL\n AND \"timeline_activity_days\".\"privacy_domain_key\" IS NULL\n AND \"timeline_activity_days\".\"privacy_policy_version\" IS NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.timeline_editions": { + "name": "timeline_editions", + "schema": "", + "columns": { + "agent_model": { + "name": "agent_model", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "edition": { + "name": "edition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "edition_key": { + "name": "edition_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "privacy_policy_version": { + "name": "privacy_policy_version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "timeline_edition_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "window_end": { + "name": "window_end", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "date", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_edition_key_idx": { + "name": "timeline_edition_key_idx", + "columns": [ + { + "expression": "edition_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_edition_published_idx": { + "name": "timeline_edition_published_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.timeline_sync_runs": { + "name": "timeline_sync_runs", + "schema": "", + "columns": { + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "coverage": { + "name": "coverage", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'partial'" + }, + "error_code": { + "name": "error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "full_window": { + "name": "full_window", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "timeline_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "window_end": { + "name": "window_end", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "date", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_sync_started_idx": { + "name": "timeline_sync_started_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.timeline_webhook_receipts": { + "name": "timeline_webhook_receipts", + "schema": "", + "columns": { + "delivery_key": { + "name": "delivery_key", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_webhook_expiry_idx": { + "name": "timeline_webhook_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.timeline_edition_status": { + "name": "timeline_edition_status", + "schema": "public", + "values": ["draft", "published", "rejected"] + }, + "public.timeline_sync_status": { + "name": "timeline_sync_status", + "schema": "public", + "values": ["running", "completed", "failed"] + }, + "public.timeline_visibility": { + "name": "timeline_visibility", + "schema": "public", + "values": ["public", "private"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..7128a1d --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,727 @@ +{ + "id": "b27e2e74-54d7-4719-97c8-a2369d7aaf5c", + "prevId": "3a720773-2bd7-49e2-94e9-29b050dbf5dd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.timeline_activity_days": { + "name": "timeline_activity_days", + "schema": "", + "columns": { + "bucket": { + "name": "bucket", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "commit_count": { + "name": "commit_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "language_family": { + "name": "language_family", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "privacy_domain_key": { + "name": "privacy_domain_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "privacy_policy_version": { + "name": "privacy_policy_version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "public_repo_name": { + "name": "public_repo_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "public_repo_url": { + "name": "public_repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reached_default_branch": { + "name": "reached_default_branch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "repo_key": { + "name": "repo_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'github-profile'" + }, + "subject": { + "name": "subject", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "visibility": { + "name": "visibility", + "type": "timeline_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_activity_day_idx": { + "name": "timeline_activity_day_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_activity_visibility_idx": { + "name": "timeline_activity_visibility_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_activity_repo_day_source_idx": { + "name": "timeline_activity_repo_day_source_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "timeline_activity_positive_count": { + "name": "timeline_activity_positive_count", + "value": "\"timeline_activity_days\".\"commit_count\" > 0" + }, + "timeline_activity_visibility_boundary": { + "name": "timeline_activity_visibility_boundary", + "value": "(\n \"timeline_activity_days\".\"visibility\" = 'private'\n AND \"timeline_activity_days\".\"public_repo_name\" IS NULL\n AND \"timeline_activity_days\".\"public_repo_url\" IS NULL\n AND \"timeline_activity_days\".\"privacy_policy_version\" IS NOT NULL\n ) OR (\n \"timeline_activity_days\".\"visibility\" = 'public'\n AND \"timeline_activity_days\".\"public_repo_name\" IS NOT NULL\n AND \"timeline_activity_days\".\"public_repo_url\" IS NOT NULL\n AND \"timeline_activity_days\".\"privacy_domain_key\" IS NULL\n AND \"timeline_activity_days\".\"privacy_policy_version\" IS NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.timeline_editions": { + "name": "timeline_editions", + "schema": "", + "columns": { + "agent_model": { + "name": "agent_model", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "edition": { + "name": "edition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "edition_key": { + "name": "edition_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "privacy_policy_version": { + "name": "privacy_policy_version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "timeline_edition_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "window_end": { + "name": "window_end", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "date", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_edition_key_idx": { + "name": "timeline_edition_key_idx", + "columns": [ + { + "expression": "edition_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_edition_published_idx": { + "name": "timeline_edition_published_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.timeline_public_events": { + "name": "timeline_public_events", + "schema": "", + "columns": { + "bucket": { + "name": "bucket", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "event_kind": { + "name": "event_kind", + "type": "timeline_public_event_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "public_repo_name": { + "name": "public_repo_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "public_repo_url": { + "name": "public_repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_title": { + "name": "public_title", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true + }, + "public_url": { + "name": "public_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_key": { + "name": "repo_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'github-profile'" + }, + "subject": { + "name": "subject", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "timeline_public_event_day_idx": { + "name": "timeline_public_event_day_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_public_event_repo_day_idx": { + "name": "timeline_public_event_repo_day_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_public_event_kind_day_idx": { + "name": "timeline_public_event_kind_day_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "timeline_public_event_identity_shape": { + "name": "timeline_public_event_identity_shape", + "value": "\"timeline_public_events\".\"id\" ~ '^[a-f0-9]{64}$'\n AND \"timeline_public_events\".\"repo_key\" ~ '^[a-f0-9]{64}$'\n AND \"timeline_public_events\".\"subject\" ~ '^[A-Za-z0-9][A-Za-z0-9-]{0,38}$'\n AND \"timeline_public_events\".\"subject\" !~ '--'\n AND \"timeline_public_events\".\"subject\" !~ '-$'\n AND \"timeline_public_events\".\"public_repo_name\" ~ '^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/[A-Za-z0-9._-]{1,100}$'" + }, + "timeline_public_event_public_boundary": { + "name": "timeline_public_event_public_boundary", + "value": "\"timeline_public_events\".\"public_repo_url\" = 'https://github.com/' || \"timeline_public_events\".\"public_repo_name\"\n AND length(\"timeline_public_events\".\"public_url\") <= 500\n AND \"timeline_public_events\".\"public_title\" = btrim(\"timeline_public_events\".\"public_title\")\n AND length(\"timeline_public_events\".\"public_title\") > 0\n AND \"timeline_public_events\".\"public_title\" !~ '[[:cntrl:]]'\n AND (\n (\"timeline_public_events\".\"event_kind\" = 'issue_opened'\n AND substring(\"timeline_public_events\".\"public_url\" from length(\"timeline_public_events\".\"public_repo_url\") + 1) ~ '^/issues/[0-9]+$')\n OR (\"timeline_public_events\".\"event_kind\" IN ('pull_request_opened', 'pull_request_reviewed')\n AND substring(\"timeline_public_events\".\"public_url\" from length(\"timeline_public_events\".\"public_repo_url\") + 1) ~ '^/pull/[0-9]+$')\n OR (\"timeline_public_events\".\"event_kind\" = 'repository_created'\n AND \"timeline_public_events\".\"public_url\" = \"timeline_public_events\".\"public_repo_url\")\n )" + }, + "timeline_public_event_bucket_boundary": { + "name": "timeline_public_event_bucket_boundary", + "value": "\"timeline_public_events\".\"bucket\" IN (\n 'Applied AI',\n 'Open source',\n 'Product systems',\n 'Infrastructure',\n 'Writing'\n )" + }, + "timeline_public_event_source_boundary": { + "name": "timeline_public_event_source_boundary", + "value": "\"timeline_public_events\".\"source\" = 'github-profile'" + } + }, + "isRLSEnabled": false + }, + "public.timeline_sync_runs": { + "name": "timeline_sync_runs", + "schema": "", + "columns": { + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "coverage": { + "name": "coverage", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'partial'" + }, + "error_code": { + "name": "error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "event_count": { + "name": "event_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "full_window": { + "name": "full_window", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "public_event_coverage": { + "name": "public_event_coverage", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'unavailable'" + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "timeline_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "window_end": { + "name": "window_end", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "date", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_sync_started_idx": { + "name": "timeline_sync_started_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.timeline_webhook_receipts": { + "name": "timeline_webhook_receipts", + "schema": "", + "columns": { + "delivery_key": { + "name": "delivery_key", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_webhook_expiry_idx": { + "name": "timeline_webhook_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.timeline_edition_status": { + "name": "timeline_edition_status", + "schema": "public", + "values": ["draft", "published", "rejected"] + }, + "public.timeline_public_event_kind": { + "name": "timeline_public_event_kind", + "schema": "public", + "values": [ + "issue_opened", + "pull_request_opened", + "pull_request_reviewed", + "repository_created" + ] + }, + "public.timeline_sync_status": { + "name": "timeline_sync_status", + "schema": "public", + "values": ["running", "completed", "failed"] + }, + "public.timeline_visibility": { + "name": "timeline_visibility", + "schema": "public", + "values": ["public", "private"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..6936417 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,850 @@ +{ + "id": "0bcc146b-8dc5-49f4-9077-d3cbcbc80713", + "prevId": "b27e2e74-54d7-4719-97c8-a2369d7aaf5c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.timeline_activity_days": { + "name": "timeline_activity_days", + "schema": "", + "columns": { + "bucket": { + "name": "bucket", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "commit_count": { + "name": "commit_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "language_family": { + "name": "language_family", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "privacy_domain_key": { + "name": "privacy_domain_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "privacy_policy_version": { + "name": "privacy_policy_version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "public_repo_name": { + "name": "public_repo_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "public_repo_url": { + "name": "public_repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reached_default_branch": { + "name": "reached_default_branch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "repo_key": { + "name": "repo_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'github-profile'" + }, + "subject": { + "name": "subject", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "visibility": { + "name": "visibility", + "type": "timeline_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_activity_day_idx": { + "name": "timeline_activity_day_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_activity_visibility_idx": { + "name": "timeline_activity_visibility_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_activity_repo_day_source_idx": { + "name": "timeline_activity_repo_day_source_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "timeline_activity_positive_count": { + "name": "timeline_activity_positive_count", + "value": "\"timeline_activity_days\".\"commit_count\" > 0" + }, + "timeline_activity_visibility_boundary": { + "name": "timeline_activity_visibility_boundary", + "value": "(\n \"timeline_activity_days\".\"visibility\" = 'private'\n AND \"timeline_activity_days\".\"public_repo_name\" IS NULL\n AND \"timeline_activity_days\".\"public_repo_url\" IS NULL\n AND \"timeline_activity_days\".\"privacy_policy_version\" IS NOT NULL\n ) OR (\n \"timeline_activity_days\".\"visibility\" = 'public'\n AND \"timeline_activity_days\".\"public_repo_name\" IS NOT NULL\n AND \"timeline_activity_days\".\"public_repo_url\" IS NOT NULL\n AND \"timeline_activity_days\".\"privacy_domain_key\" IS NULL\n AND \"timeline_activity_days\".\"privacy_policy_version\" IS NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.timeline_contribution_totals": { + "name": "timeline_contribution_totals", + "schema": "", + "columns": { + "contribution_count": { + "name": "contribution_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'github-public-calendar'" + }, + "subject": { + "name": "subject", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "timeline_contribution_total_day_idx": { + "name": "timeline_contribution_total_day_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_contribution_total_day_source_idx": { + "name": "timeline_contribution_total_day_source_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "timeline_contribution_total_nonnegative_count": { + "name": "timeline_contribution_total_nonnegative_count", + "value": "\"timeline_contribution_totals\".\"contribution_count\" >= 0" + }, + "timeline_contribution_total_identity_shape": { + "name": "timeline_contribution_total_identity_shape", + "value": "\"timeline_contribution_totals\".\"id\" ~ '^[a-f0-9]{64}$'\n AND \"timeline_contribution_totals\".\"subject\" ~ '^[A-Za-z0-9][A-Za-z0-9-]{0,38}$'\n AND \"timeline_contribution_totals\".\"subject\" !~ '--'\n AND \"timeline_contribution_totals\".\"subject\" !~ '-$'\n AND \"timeline_contribution_totals\".\"source\" = 'github-public-calendar'" + } + }, + "isRLSEnabled": false + }, + "public.timeline_editions": { + "name": "timeline_editions", + "schema": "", + "columns": { + "agent_model": { + "name": "agent_model", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "edition": { + "name": "edition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "edition_key": { + "name": "edition_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "privacy_policy_version": { + "name": "privacy_policy_version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "timeline_edition_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "window_end": { + "name": "window_end", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "date", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_edition_key_idx": { + "name": "timeline_edition_key_idx", + "columns": [ + { + "expression": "edition_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_edition_published_idx": { + "name": "timeline_edition_published_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.timeline_public_events": { + "name": "timeline_public_events", + "schema": "", + "columns": { + "bucket": { + "name": "bucket", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "event_kind": { + "name": "event_kind", + "type": "timeline_public_event_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "public_repo_name": { + "name": "public_repo_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "public_repo_url": { + "name": "public_repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_title": { + "name": "public_title", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true + }, + "public_url": { + "name": "public_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_key": { + "name": "repo_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'github-profile'" + }, + "subject": { + "name": "subject", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "timeline_public_event_day_idx": { + "name": "timeline_public_event_day_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_public_event_repo_day_idx": { + "name": "timeline_public_event_repo_day_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "timeline_public_event_kind_day_idx": { + "name": "timeline_public_event_kind_day_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "timeline_public_event_identity_shape": { + "name": "timeline_public_event_identity_shape", + "value": "\"timeline_public_events\".\"id\" ~ '^[a-f0-9]{64}$'\n AND \"timeline_public_events\".\"repo_key\" ~ '^[a-f0-9]{64}$'\n AND \"timeline_public_events\".\"subject\" ~ '^[A-Za-z0-9][A-Za-z0-9-]{0,38}$'\n AND \"timeline_public_events\".\"subject\" !~ '--'\n AND \"timeline_public_events\".\"subject\" !~ '-$'\n AND \"timeline_public_events\".\"public_repo_name\" ~ '^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/[A-Za-z0-9._-]{1,100}$'" + }, + "timeline_public_event_public_boundary": { + "name": "timeline_public_event_public_boundary", + "value": "\"timeline_public_events\".\"public_repo_url\" = 'https://github.com/' || \"timeline_public_events\".\"public_repo_name\"\n AND length(\"timeline_public_events\".\"public_url\") <= 500\n AND \"timeline_public_events\".\"public_title\" = btrim(\"timeline_public_events\".\"public_title\")\n AND length(\"timeline_public_events\".\"public_title\") > 0\n AND \"timeline_public_events\".\"public_title\" !~ '[[:cntrl:]]'\n AND (\n (\"timeline_public_events\".\"event_kind\" = 'issue_opened'\n AND substring(\"timeline_public_events\".\"public_url\" from length(\"timeline_public_events\".\"public_repo_url\") + 1) ~ '^/issues/[0-9]+$')\n OR (\"timeline_public_events\".\"event_kind\" IN ('pull_request_opened', 'pull_request_reviewed')\n AND substring(\"timeline_public_events\".\"public_url\" from length(\"timeline_public_events\".\"public_repo_url\") + 1) ~ '^/pull/[0-9]+$')\n OR (\"timeline_public_events\".\"event_kind\" = 'repository_created'\n AND \"timeline_public_events\".\"public_url\" = \"timeline_public_events\".\"public_repo_url\")\n )" + }, + "timeline_public_event_bucket_boundary": { + "name": "timeline_public_event_bucket_boundary", + "value": "\"timeline_public_events\".\"bucket\" IN (\n 'Applied AI',\n 'Open source',\n 'Product systems',\n 'Infrastructure',\n 'Writing'\n )" + }, + "timeline_public_event_source_boundary": { + "name": "timeline_public_event_source_boundary", + "value": "\"timeline_public_events\".\"source\" = 'github-profile'" + } + }, + "isRLSEnabled": false + }, + "public.timeline_sync_runs": { + "name": "timeline_sync_runs", + "schema": "", + "columns": { + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "coverage": { + "name": "coverage", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'partial'" + }, + "error_code": { + "name": "error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "event_count": { + "name": "event_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "anonymous_day_count": { + "name": "anonymous_day_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "anonymous_coverage": { + "name": "anonymous_coverage", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'unavailable'" + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "full_window": { + "name": "full_window", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "public_event_coverage": { + "name": "public_event_coverage", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'unavailable'" + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "timeline_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "window_end": { + "name": "window_end", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "date", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_sync_started_idx": { + "name": "timeline_sync_started_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.timeline_webhook_receipts": { + "name": "timeline_webhook_receipts", + "schema": "", + "columns": { + "delivery_key": { + "name": "delivery_key", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "timeline_webhook_expiry_idx": { + "name": "timeline_webhook_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.timeline_edition_status": { + "name": "timeline_edition_status", + "schema": "public", + "values": ["draft", "published", "rejected"] + }, + "public.timeline_public_event_kind": { + "name": "timeline_public_event_kind", + "schema": "public", + "values": [ + "issue_opened", + "pull_request_opened", + "pull_request_reviewed", + "repository_created" + ] + }, + "public.timeline_sync_status": { + "name": "timeline_sync_status", + "schema": "public", + "values": ["running", "completed", "failed"] + }, + "public.timeline_visibility": { + "name": "timeline_visibility", + "schema": "public", + "values": ["public", "private"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..d4c9e27 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,27 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1786553338113, + "tag": "0000_timeline", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1786560424907, + "tag": "0001_eager_stranger", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1786609529721, + "tag": "0002_warm_killer_shrike", + "breakpoints": true + } + ] +} diff --git a/mise.toml b/mise.toml index 829f6ab..18c6543 100644 --- a/mise.toml +++ b/mise.toml @@ -4,7 +4,7 @@ lockfile = true [tools] bun = "1.3.14" hk = "1.48.0" -node = "26.3.1" +node = "24.18.0" pkl = "0.31.1" typst = "0.15.0" typstyle = "0.15.0" diff --git a/next.config.ts b/next.config.ts index 06739a0..0888e90 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,6 @@ import "./src/env"; import createMDX from "@next/mdx"; +import { withEve } from "eve/next"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { @@ -10,6 +11,7 @@ const nextConfig: NextConfig = { "/*": ["./next.config.ts"], }, outputFileTracingIncludes: { + "/": ["./src/content/**/*"], "/blog/[slug]": ["./src/content/**/*"], "/blog/[slug]/markdown": ["./src/content/**/*"], "/blog/[slug]/opengraph-image": ["./src/content/**/*"], @@ -75,4 +77,4 @@ const withMDX = createMDX({ }, }); -export default withMDX(nextConfig); +export default withEve(withMDX(nextConfig)); diff --git a/package.json b/package.json index a1dba30..95fc50d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "scripts": { "dev": "next dev", "build": "next build", + "build:agent": "eve build", + "build:local": "eve build && next build", "start": "next start", "test": "bun test", "typecheck": "tsgo --noEmit", @@ -17,24 +19,35 @@ "format:ox": "oxfmt --write", "format:check": "oxfmt --check", "format:typ": "typstyle -i career", - "format:typ:check": "typstyle --check career" + "format:typ:check": "typstyle --check career", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", + "timeline:sync": "bun scripts/sync-timeline.ts" }, "dependencies": { + "@ai-sdk/gateway": "4.0.50", + "@ai-sdk/openai": "4.0.41", "@base-ui/react": "^1.6.0", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@mermaid-js/layout-elk": "0.1.9", + "@neondatabase/serverless": "1.1.0", "@next/mdx": "^16.2.12", "@remark-embedder/core": "^3.0.3", "@t3-oss/env-nextjs": "^0.13.11", "@tailwindcss/typography": "^0.5.20", + "ai": "7.0.63", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "drizzle-orm": "0.45.2", + "eve": "0.33.3", "feed": "^5.2.1", "lucide-react": "^1.21.0", "mermaid": "^11.16.0", "next": "^16.2.12", "next-themes": "^0.4.6", + "postgres": "3.4.7", "react": "^19.2.8", "react-dom": "^19.2.8", "reading-time": "^1.5.0", @@ -51,11 +64,12 @@ "devDependencies": { "@tailwindcss/postcss": "^4.3.1", "@types/mdx": "^2.0.14", - "@types/node": "^26.0.1", + "@types/node": "24.13.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@typescript/native-preview": "^7.0.0-dev.20260624.1", "babel-plugin-react-compiler": "1.0.0", + "drizzle-kit": "0.31.10", "oxfmt": "^0.56.0", "oxlint": "^1.71.0", "oxlint-tsgolint": "^0.23.0", @@ -64,5 +78,9 @@ "tw-animate-css": "^1.4.0", "typescript": "^7.0.1-rc", "ultracite": "^7.8.3" - } + }, + "engines": { + "node": "24.x" + }, + "packageManager": "bun@1.3.14" } diff --git a/scripts/sync-timeline.ts b/scripts/sync-timeline.ts new file mode 100644 index 0000000..a12c8d4 --- /dev/null +++ b/scripts/sync-timeline.ts @@ -0,0 +1,31 @@ +import { closeTimelineDatabase } from "../src/db/client"; +import { syncGitHubTimeline } from "../src/lib/timeline-github"; + +const forceBackfill = process.argv.includes("--backfill"); + +try { + const result = await syncGitHubTimeline({ + forceBackfill, + kind: forceBackfill ? "backfill" : "manual", + }); + process.stdout.write( + `${JSON.stringify({ + anonymousCoverage: result.anonymousCoverage, + anonymousDays: result.anonymousDays, + coverage: result.coverage, + events: result.events, + kind: result.kind, + privateActivity: result.privateActivity, + rows: result.rows, + windowEnd: result.windowEnd, + windowStart: result.windowStart, + })}\n` + ); +} catch { + process.stderr.write( + "Timeline sync failed. Check configuration and sync_runs.\n" + ); + process.exitCode = 1; +} finally { + await closeTimelineDatabase(); +} diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..32ce069 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "skills": { + "neon": { + "source": "neondatabase/agent-skills", + "sourceType": "github", + "skillPath": "skills/neon/SKILL.md", + "computedHash": "6338251b632f9d362c845d0c2a6eb03cf8e5cede98904dee4e3633718d77284f" + }, + "neon-postgres": { + "source": "neondatabase/agent-skills", + "sourceType": "github", + "skillPath": "skills/neon-postgres/SKILL.md", + "computedHash": "58b7ffd550cc8a17c86250999cbdd0020bcb839d2389d423227272743c8d2fb8" + } + } +} diff --git a/src/app/(portfolio)/page.tsx b/src/app/(portfolio)/page.tsx index 0119641..24ddae8 100644 --- a/src/app/(portfolio)/page.tsx +++ b/src/app/(portfolio)/page.tsx @@ -1,35 +1,232 @@ +import { ArrowDown, ArrowUpRight, Star } from "lucide-react"; import type { Metadata } from "next"; +import Link from "next/link"; -import { resumeData } from "@/content/resume"; +import { GitHubTimeline } from "@/components/github-timeline"; +import { SiteShell } from "@/components/site-shell"; +import { featuredProjectNames, projectEditorial } from "@/content/home"; +import { getBlogPosts } from "@/lib/blog-utils"; +import { formatDate } from "@/lib/date"; +import { getGitHubProfile } from "@/lib/github-profile"; import { publicUrl, siteConfig } from "@/lib/site"; +import { + getPublishedTimelineEdition, + resolveTimelineEdition, +} from "@/lib/timeline"; -import { ResumePageContent } from "./resume/page"; - -const resumeDescription = siteConfig.description; +const description = + "Sid Jain is an applied AI engineer building useful, durable products and production systems. Explore his open-source work, GitHub activity, and writing."; export const metadata: Metadata = { alternates: { canonical: "/", }, - description: resumeDescription, + description, openGraph: { - description: resumeDescription, - images: [resumeData.person.image], + description, + images: [ + { + alt: "Sid Jain — Applied AI engineer", + height: 630, + url: "/opengraph-image", + width: 1200, + }, + ], locale: siteConfig.locale, siteName: siteConfig.name, - title: "Sid Jain Résumé", - type: "profile", + title: "Sid Jain — Applied AI engineer", + type: "website", url: publicUrl("/"), }, - title: "Résumé", + title: { + absolute: "Sid Jain — Applied AI engineer", + }, twitter: { - card: "summary", - description: resumeDescription, - images: [resumeData.person.image], - title: "Sid Jain Résumé", + card: "summary_large_image", + description, + images: [ + { + alt: "Sid Jain — Applied AI engineer", + height: 630, + url: "/opengraph-image", + width: 1200, + }, + ], + title: "Sid Jain — Applied AI engineer", }, }; -export default function Home() { - return ; +export const revalidate = 900; + +export default async function Home() { + const [github, posts, publishedTimeline] = await Promise.all([ + getGitHubProfile(), + getBlogPosts(), + getPublishedTimelineEdition(), + ]); + const timelineEdition = resolveTimelineEdition(github, publishedTimeline); + const projectByName = new Map( + github.projects.map((project) => [project.name, project]) + ); + const featuredProjects = featuredProjectNames.flatMap((name) => { + const project = projectByName.get(name); + return project === undefined ? [] : [project]; + }); + const projects = featuredProjects.slice(0, 4); + const recentPosts = posts.slice(0, 3); + + return ( + +
+
+
+

+ Building AI products that hold up in the real world. +

+

+ I’m Sid Jain, an applied AI engineer. I take ambiguous problems + from discovery to production—shaping the product, designing the + system, and staying for the operational details. +

+
+ + Follow the work + + + Résumé +
+
+
+ + + +
+ + +
+
+

+ Recent writing +

+ + All notes + +
+
    + {recentPosts.map((post) => ( +
  1. + +

    + {post.metadata.title} +

    +

    + {post.metadata.summary} +

    +

    + + + {post.readingTime} +

    + +
  2. + ))} +
+
+
+
+
+ ); } diff --git a/src/app/api/cron/timeline-sync/route.ts b/src/app/api/cron/timeline-sync/route.ts new file mode 100644 index 0000000..9082d54 --- /dev/null +++ b/src/app/api/cron/timeline-sync/route.ts @@ -0,0 +1,37 @@ +import { revalidateTag } from "next/cache"; +import { NextResponse } from "next/server"; + +import { hasBearerSecret } from "@/lib/request-auth"; +import { syncGitHubTimeline } from "@/lib/timeline-github"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 300; +export const runtime = "nodejs"; + +export async function GET(request: Request) { + if ( + !hasBearerSecret( + request.headers.get("authorization"), + process.env.CRON_SECRET + ) + ) { + return NextResponse.json({ ok: false }, { status: 401 }); + } + + try { + const result = await syncGitHubTimeline(); + revalidateTag("timeline-edition", "max"); + return NextResponse.json({ + anonymousCoverage: result.anonymousCoverage, + anonymousDays: result.anonymousDays, + coverage: result.coverage, + events: result.events, + kind: result.kind, + ok: true, + privateActivity: result.privateActivity, + rows: result.rows, + }); + } catch { + return NextResponse.json({ ok: false }, { status: 503 }); + } +} diff --git a/src/app/api/github/webhook/route.ts b/src/app/api/github/webhook/route.ts new file mode 100644 index 0000000..8917191 --- /dev/null +++ b/src/app/api/github/webhook/route.ts @@ -0,0 +1,139 @@ +import { createHmac } from "node:crypto"; + +import { revalidateTag } from "next/cache"; +import { after, NextResponse } from "next/server"; + +import { isTimelineDatabaseConfigured } from "@/db/client"; +import { constantTimeEqual } from "@/lib/request-auth"; +import { syncGitHubTimeline } from "@/lib/timeline-github"; +import { normalizeTimelinePrivacyKey } from "@/lib/timeline-privacy"; +import { + deletePrivateTimelineActivity, + deleteTimelineActivityByRepoKey, + deleteTimelinePublicEventsByRepoKey, + markTimelineWebhookProcessed, + pruneTimelineWebhookReceipts, + recordTimelineWebhookReceipt, + rejectPublishedTimelineEditions, +} from "@/lib/timeline-store"; +import { timelineRevocationFromWebhook } from "@/lib/timeline-webhook"; + +const maximumPayloadBytes = 2_000_000; +const supportedEvents = new Set([ + "installation", + "installation_repositories", + "push", + "repository", +]); + +export const dynamic = "force-dynamic"; +export const maxDuration = 300; +export const runtime = "nodejs"; + +const verifySignature = ( + body: string, + signature: string | null, + secret: string +) => { + if (signature === null || !/^sha256=[a-f\d]{64}$/i.test(signature)) { + return false; + } + + const expected = `sha256=${createHmac("sha256", secret) + .update(body, "utf-8") + .digest("hex")}`; + return constantTimeEqual(signature.toLocaleLowerCase("en-US"), expected); +}; + +export async function POST(request: Request) { + const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET?.trim(); + if ( + webhookSecret === undefined || + webhookSecret.length < 24 || + !isTimelineDatabaseConfigured() + ) { + return NextResponse.json({ ok: false }, { status: 503 }); + } + + const declaredLength = Number(request.headers.get("content-length") ?? "0"); + if (Number.isFinite(declaredLength) && declaredLength > maximumPayloadBytes) { + return NextResponse.json({ ok: false }, { status: 413 }); + } + + const body = await request.text(); + if (Buffer.byteLength(body, "utf-8") > maximumPayloadBytes) { + return NextResponse.json({ ok: false }, { status: 413 }); + } + + if ( + !verifySignature( + body, + request.headers.get("x-hub-signature-256"), + webhookSecret + ) + ) { + return NextResponse.json({ ok: false }, { status: 401 }); + } + + const eventType = request.headers.get("x-github-event") ?? ""; + const deliveryId = request.headers.get("x-github-delivery") ?? ""; + if ( + !supportedEvents.has(eventType) || + !/^[a-z\d-]{8,100}$/i.test(deliveryId) + ) { + return NextResponse.json({ ignored: true, ok: true }, { status: 202 }); + } + + const privacyKey = normalizeTimelinePrivacyKey( + process.env.TIMELINE_PRIVACY_KEY + ); + const receiptKey = createHmac("sha256", privacyKey ?? webhookSecret) + .update(`github-delivery:${deliveryId}`, "utf-8") + .digest("hex"); + const revocation = timelineRevocationFromWebhook(body, eventType, privacyKey); + const shouldRevoke = + revocation.repoKeys.length > 0 || revocation.withdrawAllPrivateActivity; + + try { + await pruneTimelineWebhookReceipts(); + const accepted = await recordTimelineWebhookReceipt({ + deliveryKey: receiptKey, + eventType, + expiresAt: new Date(Date.now() + 7 * 86_400_000), + }); + + if (!accepted) { + return NextResponse.json({ duplicate: true, ok: true }, { status: 202 }); + } + + after(async () => { + try { + if (shouldRevoke) { + if (revocation.withdrawAllPrivateActivity) { + await deletePrivateTimelineActivity("f0rr0"); + } + for (const repoKey of revocation.repoKeys) { + await Promise.all([ + deleteTimelineActivityByRepoKey("f0rr0", repoKey), + deleteTimelinePublicEventsByRepoKey("f0rr0", repoKey), + ]); + } + await rejectPublishedTimelineEditions(); + revalidateTag("github-profile", "max"); + revalidateTag("timeline-edition", "max"); + } + await syncGitHubTimeline({ + forceBackfill: shouldRevoke, + kind: "webhook", + }); + await markTimelineWebhookProcessed(receiptKey); + } catch { + // The sync run records a non-sensitive failure code for reconciliation. + } + }); + + return NextResponse.json({ accepted: true, ok: true }, { status: 202 }); + } catch { + return NextResponse.json({ ok: false }, { status: 503 }); + } +} diff --git a/src/app/fonts/Geist-Latin.woff2 b/src/app/fonts/Geist-Latin.woff2 new file mode 100644 index 0000000..991445d Binary files /dev/null and b/src/app/fonts/Geist-Latin.woff2 differ diff --git a/src/app/fonts/JetBrainsMono-Latin.woff2 b/src/app/fonts/JetBrainsMono-Latin.woff2 new file mode 100644 index 0000000..5858873 Binary files /dev/null and b/src/app/fonts/JetBrainsMono-Latin.woff2 differ diff --git a/src/app/fonts/Literata-Latin.woff2 b/src/app/fonts/Literata-Latin.woff2 new file mode 100644 index 0000000..be435da Binary files /dev/null and b/src/app/fonts/Literata-Latin.woff2 differ diff --git a/src/app/fonts/SourceSans3-Latin.woff2 b/src/app/fonts/SourceSans3-Latin.woff2 new file mode 100644 index 0000000..864cc41 Binary files /dev/null and b/src/app/fonts/SourceSans3-Latin.woff2 differ diff --git a/src/app/globals.css b/src/app/globals.css index 7e17c07..362a2e8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -98,6 +98,326 @@ @apply text-xs font-semibold uppercase tracking-[0.22em] text-primary; } + .home-section { + margin-top: clamp(5rem, 8vw, 6rem); + scroll-margin-top: 2rem; + } + + .activity-week { + opacity: 0.12; + transform-origin: bottom; + } + + .activity-week[data-level="1"] { + opacity: 0.35; + } + + .activity-week[data-level="2"] { + opacity: 0.55; + } + + .activity-week[data-level="3"] { + opacity: 0.75; + } + + .activity-week[data-level="4"] { + opacity: 1; + } + + .sacred-timeline { + --timeline-axis: 0.5rem; + position: relative; + } + + .sacred-timeline::before { + position: absolute; + top: 0.5rem; + bottom: 0.5rem; + left: var(--timeline-axis); + width: 1px; + background: color-mix(in oklab, var(--primary) 58%, var(--border)); + content: ""; + } + + .sacred-timeline-item { + --entry-space: 3rem; + --node-size: 0.625rem; + position: relative; + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); + column-gap: 1rem; + } + + .sacred-timeline-item[data-importance="lead"] { + --entry-space: 4rem; + --node-size: 0.875rem; + } + + .sacred-timeline-item[data-importance="compact"] { + --entry-space: 2.25rem; + --node-size: 0.3125rem; + } + + .sacred-timeline-date { + grid-column: 2; + color: var(--muted-foreground); + font-family: var(--font-site-mono); + font-size: 0.6875rem; + letter-spacing: 0.08em; + line-height: 1.5; + text-transform: uppercase; + } + + .sacred-timeline-node { + position: relative; + z-index: 1; + grid-row: 1 / span 2; + grid-column: 1; + justify-self: center; + width: var(--node-size); + height: var(--node-size); + margin-top: 0.2rem; + border: 2px solid var(--background); + border-radius: 999px; + background: var(--primary); + box-shadow: 0 0 0 1px color-mix(in oklab, var(--primary) 76%, var(--border)); + transition: + background-color 180ms ease, + box-shadow 180ms ease, + transform 180ms ease; + } + + .sacred-timeline-node::after { + position: absolute; + top: calc(50% - 0.5px); + left: 100%; + width: 1.25rem; + height: 1px; + background: color-mix(in oklab, var(--primary) 38%, var(--border)); + content: ""; + } + + .sacred-timeline-item[data-cadence="streak"] .sacred-timeline-node { + width: 0.75rem; + height: 1.75rem; + margin-top: -0.3rem; + } + + .sacred-timeline-item:hover .sacred-timeline-node, + .sacred-timeline-item:focus-within .sacred-timeline-node { + background: var(--brand-hover); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 14%, transparent); + transform: scale(1.08); + } + + .sacred-timeline-item[data-private="true"] .sacred-timeline-node { + background: var(--background); + box-shadow: 0 0 0 1px var(--muted-foreground); + } + + .sacred-timeline-content { + grid-column: 2; + padding: 0.35rem 0 var(--entry-space); + } + + .sacred-timeline-item:last-child .sacred-timeline-content { + padding-bottom: 0; + } + + .sacred-timeline-title { + margin-top: 0.5rem; + color: var(--foreground); + font-family: var(--font-site-serif); + font-size: 1.45rem; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.18; + } + + [data-importance="lead"] .sacred-timeline-title { + font-size: clamp(1.8rem, 5vw, 2.25rem); + letter-spacing: -0.03em; + line-height: 1.08; + } + + .sacred-timeline-summary { + max-width: 42rem; + margin-top: 0.6rem; + color: var(--muted-foreground); + font-size: 0.9375rem; + line-height: 1.65; + } + + [data-importance="lead"] .sacred-timeline-summary { + font-size: 1rem; + } + + .sacred-timeline-dispatches { + display: grid; + border-block: 1px solid var(--border); + } + + .sacred-timeline-dispatch { + display: grid; + grid-template-columns: 3.25rem minmax(0, 1fr); + column-gap: 0.75rem; + align-items: start; + padding-block: 0.875rem; + border-top: 1px solid var(--border); + } + + .sacred-timeline-dispatch:first-child { + border-top: 0; + } + + .sacred-timeline-dispatch-title { + margin-top: 0.25rem; + color: var(--foreground); + font-family: var(--font-site-serif); + font-size: 1.125rem; + font-weight: 700; + letter-spacing: -0.015em; + line-height: 1.25; + } + + .sacred-timeline-dispatch-date { + grid-row: 1 / span 2; + grid-column: 1; + padding-top: 0.05rem; + color: var(--muted-foreground); + font-family: var(--font-site-mono); + font-size: 0.625rem; + letter-spacing: 0.08em; + line-height: 1.5; + text-transform: uppercase; + } + + .sacred-timeline-dispatch-kind { + grid-row: 1; + grid-column: 2; + color: var(--primary); + font-family: var(--font-site-ui); + font-size: 0.625rem; + font-weight: 650; + letter-spacing: 0.12em; + line-height: 1.5; + text-transform: uppercase; + } + + .sacred-timeline-dispatch-body { + min-width: 0; + grid-row: 2; + grid-column: 2; + padding-top: 0.25rem; + } + + .sacred-timeline-dispatch-link { + display: inline-flex; + min-height: 1.5rem; + align-items: center; + gap: 0.25rem; + border-radius: 0.125rem; + color: inherit; + text-decoration-line: underline; + text-decoration-color: var(--border); + text-underline-offset: 0.25rem; + transition: + color 180ms ease, + text-decoration-color 180ms ease; + } + + .sacred-timeline-dispatch-link:hover { + color: var(--brand-hover); + text-decoration-color: var(--brand-hover); + } + + .sacred-timeline-dispatch-link:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 0.25rem; + } + + .sacred-timeline-dispatch-summary { + margin-top: 0.4rem; + color: var(--muted-foreground); + font-size: 0.875rem; + line-height: 1.6; + } + + [data-importance="compact"] .sacred-timeline-node { + border-width: 1px; + } + + @media (min-width: 40rem) { + .sacred-timeline { + --timeline-axis: 7.6875rem; + } + + .sacred-timeline::before { + left: var(--timeline-axis); + } + + .sacred-timeline-item { + grid-template-columns: 6.25rem 1.375rem minmax(0, 1fr); + column-gap: 0.75rem; + } + + .sacred-timeline-date { + grid-row: 1; + grid-column: 1; + padding-top: 0.05rem; + text-align: right; + } + + .sacred-timeline-node { + grid-row: 1; + grid-column: 2; + justify-self: center; + } + + .sacred-timeline-content { + grid-row: 1; + grid-column: 3; + padding: 0 0 var(--entry-space) 0.75rem; + } + } + + @media (min-width: 48rem) { + .sacred-timeline-dispatch { + grid-template-columns: 4.25rem 5.25rem minmax(0, 1fr); + column-gap: 0.75rem; + } + + .sacred-timeline-dispatch-date { + grid-row: 1; + grid-column: 1; + } + + .sacred-timeline-dispatch-kind { + grid-row: 1; + grid-column: 2; + } + + .sacred-timeline-dispatch-body { + grid-row: 1; + grid-column: 3; + padding-top: 0; + } + } + + @media (prefers-reduced-motion: no-preference) { + .activity-week { + animation: activity-rise 600ms cubic-bezier(0.2, 0.8, 0.2, 1) both; + } + } + + @media (prefers-reduced-motion: reduce) { + .sacred-timeline-node, + .group\/link svg, + .sacred-timeline-dispatch-link { + transition: none; + } + } + @media (min-width: 40rem) { .site-container { padding-inline: 2rem; @@ -111,6 +431,17 @@ } } +@keyframes activity-rise { + from { + opacity: 0.08; + transform: scaleY(0.35); + } + + to { + transform: scaleY(1); + } +} + @layer base { :root { color-scheme: light; @@ -130,6 +461,10 @@ } body { + --font-site-ui: var(--font-site-body); + --font-site-mono: + ui-monospace, "SFMono-Regular", "Cascadia Code", "Roboto Mono", Menlo, + Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: var(--font-site-body); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 18dc30e..3125e95 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,5 @@ import type { Metadata } from "next"; -import { - Geist, - JetBrains_Mono, - Literata, - Source_Sans_3, -} from "next/font/google"; +import localFont from "next/font/local"; import { JsonLd } from "@/components/json-ld"; import { ThemeProvider } from "@/components/theme-provider"; @@ -13,28 +8,20 @@ import { buildRootJsonLd } from "@/lib/structured-data"; import "./globals.css"; -const literata = Literata({ - subsets: ["latin"], +const literata = localFont({ + display: "swap", + preload: true, + src: "./fonts/Literata-Latin.woff2", variable: "--font-site-heading", - weight: ["400", "700"], + weight: "400 700", }); -const sourceSans = Source_Sans_3({ - subsets: ["latin"], +const sourceSans = localFont({ + display: "swap", + preload: true, + src: "./fonts/SourceSans3-Latin.woff2", variable: "--font-site-body", - weight: ["400", "600", "700"], -}); - -const geist = Geist({ - subsets: ["latin"], - variable: "--font-site-ui", -}); - -const jetBrainsMono = JetBrains_Mono({ - preload: false, - subsets: ["latin"], - variable: "--font-site-mono", - weight: ["400"], + weight: "400 700", }); export const metadata: Metadata = { @@ -106,7 +93,7 @@ export default function RootLayout({ return ( diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx new file mode 100644 index 0000000..05c2ce0 --- /dev/null +++ b/src/app/opengraph-image.tsx @@ -0,0 +1,106 @@ +import { ImageResponse } from "next/og"; + +export const alt = "Sid Jain — Applied AI engineer"; +export const size = { + height: 630, + width: 1200, +}; +export const contentType = "image/png"; + +export default function Image() { + return new ImageResponse( +
+
+ Sid Jain + + f0rr0.dev + +
+
+
+ Building AI products that hold up in the real world. +
+
+ + + + +
+
+
+ Applied AI · product systems · open source · writing +
+
, + size + ); +} diff --git a/src/components/github-timeline.tsx b/src/components/github-timeline.tsx new file mode 100644 index 0000000..75e05f2 --- /dev/null +++ b/src/components/github-timeline.tsx @@ -0,0 +1,502 @@ +import { ArrowUpRight, LockKeyhole } from "lucide-react"; + +import { formatDate } from "@/lib/date"; +import type { + GitHubActivity, + GitHubActivityWeek, +} from "@/lib/github-profile-core"; +import { siteConfig } from "@/lib/site"; +import type { + TimelineEdition, + TimelineEditionEntry, +} from "@/lib/timeline-core"; + +const numberFormatter = new Intl.NumberFormat("en-US"); +const monthFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + timeZone: "UTC", + year: "numeric", +}); +const monthOnlyFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + timeZone: "UTC", +}); +const dayFormatter = new Intl.DateTimeFormat("en-US", { + day: "2-digit", + month: "short", + timeZone: "UTC", +}); +const WEEK_IN_MILLISECONDS = 7 * 86_400_000; + +const asUtcDate = (date: string) => new Date(`${date}T00:00:00Z`); + +const formatTimelineRange = (start: string, end: string) => { + if (start.slice(0, 7) === end.slice(0, 7)) { + return monthFormatter.format(asUtcDate(end)); + } + + const startDate = asUtcDate(start); + const endDate = asUtcDate(end); + if (startDate.getUTCFullYear() === endDate.getUTCFullYear()) { + return `${monthOnlyFormatter.format(startDate)}—${monthFormatter.format( + endDate + )}`; + } + return `${monthFormatter.format(startDate)}—${monthFormatter.format(endDate)}`; +}; + +const isCompact = (entry: TimelineEditionEntry) => + entry.kind !== "activity" && + (entry.importance === "brief" || entry.importance === "pulse"); + +const isRenderable = (entry: TimelineEditionEntry) => + entry.kind !== "activity" || entry.cadence !== "isolated"; + +const activityWeekStats = (weeks: readonly GitHubActivityWeek[]) => { + let activeWeeks = 0; + let currentRun = 0; + let longestRun = 0; + let previousWeekStart: number | undefined; + + for (const week of weeks.toSorted((left, right) => + left.weekStart.localeCompare(right.weekStart) + )) { + const weekStart = Date.parse(`${week.weekStart}T00:00:00Z`); + const followsPrevious = + previousWeekStart !== undefined && + weekStart - previousWeekStart === WEEK_IN_MILLISECONDS; + + if (week.contributionCount > 0) { + activeWeeks += 1; + currentRun = followsPrevious ? currentRun + 1 : 1; + longestRun = Math.max(longestRun, currentRun); + } else { + currentRun = 0; + } + previousWeekStart = weekStart; + } + + return { activeWeeks, longestRun }; +}; + +type TimelineBlock = + | { entry: TimelineEditionEntry; type: "major" } + | { + entries: TimelineEditionEntry[]; + key: string; + month: string; + type: "dispatches"; + }; + +const timelineBlocksFrom = ( + entries: readonly TimelineEditionEntry[] +): TimelineBlock[] => { + const sortedEntries = entries + .filter(isRenderable) + .toSorted( + (left, right) => + right.endDate.localeCompare(left.endDate) || + right.startDate.localeCompare(left.startDate) || + left.id.localeCompare(right.id) + ); + const dispatchesByMonth = new Map(); + for (const entry of sortedEntries) { + if (!isCompact(entry)) { + continue; + } + const month = entry.endDate.slice(0, 7); + dispatchesByMonth.set(month, [ + ...(dispatchesByMonth.get(month) ?? []), + entry, + ]); + } + + const blocks: TimelineBlock[] = []; + const emittedDispatchMonths = new Set(); + for (const entry of sortedEntries) { + if (!isCompact(entry)) { + blocks.push({ entry, type: "major" }); + continue; + } + + const month = entry.endDate.slice(0, 7); + if (emittedDispatchMonths.has(month)) { + continue; + } + emittedDispatchMonths.add(month); + blocks.push({ + entries: dispatchesByMonth.get(month) ?? [entry], + key: `dispatches-${month}`, + month, + type: "dispatches", + }); + } + return blocks; +}; + +function ActivitySignal({ activity }: Readonly<{ activity: GitHubActivity }>) { + if (activity.status === "unavailable") { + return ( +

+ GitHub activity is temporarily unavailable. The selected public work + remains available elsewhere on this page. +

+ ); + } + + const { activeWeeks, longestRun } = activityWeekStats(activity.weeks); + + return ( +
+
+
+

+ Last twelve months +

+

+ {numberFormatter.format(activity.totalContributions)} contributions +

+

+ Across {numberFormatter.format(activity.activeDays)} active days.{" "} + {activity.restrictedContributions === null ? ( + <> + Public and private activity are combined. Private identifiers + are discarded before storage or editorial processing. + + ) : ( + <> + {numberFormatter.format(activity.restrictedContributions)} came + from private work; only privacy-safe aggregate patterns can + enter the edition. + + )} +

+
+
+
Active weeks
+
+ {numberFormatter.format(activeWeeks)} +
+
+
+
Longest weekly run
+
+ {numberFormatter.format(longestRun)} +
+
+
+
+ + GitHub profile + (opens in a new tab) + +
+
+ {activity.weeks.map((week) => ( +
+
+ {formatDate(new Date(activity.from), siteConfig.language)} + {formatDate(new Date(activity.to), siteConfig.language)} +
+
+ ); +} + +function EntryLink({ entry }: Readonly<{ entry: TimelineEditionEntry }>) { + if (entry.href === undefined || entry.label === undefined) { + return null; + } + + const external = entry.href.startsWith("http"); + return ( + + {entry.label} + {external ? ( + <> + (opens in a new tab) + + ); +} + +function VisibilityLabel({ entry }: Readonly<{ entry: TimelineEditionEntry }>) { + if (entry.visibility === "public") { + return null; + } + return ( + + + ); +} + +function MajorEntry({ entry }: Readonly<{ entry: TimelineEditionEntry }>) { + const editorialLabel = + entry.cadence === "streak" + ? "streak" + : entry.kind === "activity" + ? "trend" + : entry.importance; + + return ( +
  • + +
  • + ); +} + +const dispatchKindLabels = { + activity: "Activity", + issue: "Issue", + project: "Project", + "pull-request": "PR", +} as const satisfies Record; + +const repositoryLabelFrom = (entry: TimelineEditionEntry) => { + if ( + (entry.kind !== "issue" && entry.kind !== "pull-request") || + entry.href === undefined + ) { + return null; + } + try { + const url = new URL(entry.href); + const segments = url.pathname.split("/").filter(Boolean); + return url.hostname === "github.com" && segments.length >= 2 + ? `${segments[0]}/${segments[1]}` + : null; + } catch { + return null; + } +}; + +function DispatchTitle({ entry }: Readonly<{ entry: TimelineEditionEntry }>) { + if (entry.href === undefined) { + return <>{entry.title}; + } + + const external = entry.href.startsWith("http"); + return ( + + {entry.title} + {external ? ( + <> + (opens in a new tab) + + ); +} + +function DispatchGroup({ + block, +}: Readonly<{ + block: Extract; +}>) { + const hasProtectedEntry = block.entries.some( + (entry) => entry.visibility !== "public" + ); + const label = monthFormatter.format(asUtcDate(`${block.month}-01`)); + return ( +
  • + +
  • + ); +} + +export function GitHubTimeline({ + activity, + edition, +}: Readonly<{ + activity: GitHubActivity; + edition: TimelineEdition | null; +}>) { + const blocks = edition === null ? [] : timelineBlocksFrom(edition.entries); + const sectionTitle = edition?.headline ?? "The work, along one line."; + + return ( +
    +
    +

    + Rolling edition + {edition === null + ? null + : ` · ${formatTimelineRange( + edition.windowStart, + edition.windowEnd + )}`} +

    +

    + {sectionTitle} +

    +

    + {edition?.standfirst ?? + "The live edition is rebuilding; public projects and writing remain available below."} +

    + {edition === null ? null : ( +

    + Updated{" "} + +

    + )} +
    + + {edition === null ? null : ( +
      + {blocks.map((block) => + block.type === "major" ? ( + + ) : ( + + ) + )} +
    + )} +
    + ); +} diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx new file mode 100644 index 0000000..73014a9 --- /dev/null +++ b/src/components/site-footer.tsx @@ -0,0 +1,63 @@ +import Link from "next/link"; + +import { resumeData } from "@/content/resume"; + +const footerLinks = [ + { href: "https://github.com/f0rr0", label: "GitHub", external: true }, + { href: "https://linkedin.com/in/f0rr0", label: "LinkedIn", external: true }, + { + href: `mailto:${resumeData.person.email}`, + label: "Email", + external: false, + }, + { href: "/resume", label: "Résumé", external: false }, + { href: "/rss.xml", label: "RSS", external: false }, +] as const; + +export function SiteFooter(): React.ReactNode { + return ( +
    +
    +
    +

    + {resumeData.person.name} +

    +

    + Applied AI engineer building useful, durable systems from Mumbai. +

    +
    +
    + +

    + © {new Date().getUTCFullYear()} Sid Jain +

    +
    +
    +
    + ); +} diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index 9c6632b..40e33a5 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -7,13 +7,13 @@ import { resumeData } from "@/content/resume"; import { SiteMobileMenu } from "./site-mobile-menu"; interface SiteHeaderProps { - activeHref: "/blog" | "/resume"; + activeHref?: "/blog" | "/resume"; currentPath?: "/" | "/blog" | "/resume"; } export function SiteHeader({ activeHref, - currentPath = activeHref, + currentPath = activeHref ?? "/", }: Readonly): React.ReactNode { return (
    @@ -24,7 +24,9 @@ export function SiteHeader({ > diff --git a/src/components/site-mobile-menu.tsx b/src/components/site-mobile-menu.tsx index abef31b..901bd85 100644 --- a/src/components/site-mobile-menu.tsx +++ b/src/components/site-mobile-menu.tsx @@ -8,7 +8,7 @@ export function SiteMobileMenu({ currentPath, navItems, }: Readonly<{ - activeHref: "/blog" | "/resume"; + activeHref?: "/blog" | "/resume"; currentPath: "/" | "/blog" | "/resume"; navItems: readonly ResumeNavItem[]; }>) { @@ -43,6 +43,7 @@ export function SiteMobileMenu({ ) : ( diff --git a/src/components/site-shell.tsx b/src/components/site-shell.tsx index 8d6e152..909cec0 100644 --- a/src/components/site-shell.tsx +++ b/src/components/site-shell.tsx @@ -1,22 +1,26 @@ import type { ReactNode } from "react"; +import { SiteFooter } from "@/components/site-footer"; import { SiteHeader } from "@/components/site-header"; interface SiteShellProps { - activeHref: "/blog" | "/resume"; + activeHref?: "/blog" | "/resume"; children: ReactNode; currentPath?: "/" | "/blog" | "/resume"; + includeFooter?: boolean; } export function SiteShell({ activeHref, children, - currentPath = activeHref, + currentPath = activeHref ?? "/", + includeFooter = false, }: Readonly) { return (
    {children} + {includeFooter ? : null}
    ); } diff --git a/src/content/home.ts b/src/content/home.ts new file mode 100644 index 0000000..52907aa --- /dev/null +++ b/src/content/home.ts @@ -0,0 +1,136 @@ +import type { TimelineImportance, WorkBucket } from "@/lib/timeline-core"; + +export interface TimelineEntry { + bucket: WorkBucket; + date: string; + description: string; + href: string; + importance?: TimelineImportance; + label: string; + private?: boolean; + title: string; +} + +export const timelineEntries = [ + { + bucket: "Open source", + date: "2026-08-12", + description: + "Making embedded Postgres feel as ordinary as opening a SQLite database—without Docker, Node.js, or a server.", + href: "https://github.com/f0rr0/oliphaunt", + importance: "lead", + label: "Explore oliphaunt", + title: "Shipping oliphaunt", + }, + { + bucket: "Open source", + date: "2026-07-19", + description: + "A public Rust client built while mapping an opaque mobile protocol into a small, inspectable interface.", + href: "https://github.com/f0rr0/hinge-rs", + importance: "lead", + label: "View hinge-rs", + title: "Reverse-engineering in Rust", + }, + { + bucket: "Applied AI", + date: "2026-04-21", + description: + "An MCP server and scheduler that lets an agent watch a scarce appointment queue and return only when action is possible.", + href: "https://github.com/f0rr0/tranquilo", + importance: "story", + label: "View Tranquilo", + title: "Teaching an agent when to act", + }, + { + bucket: "Applied AI", + date: "2026-03-16", + description: + "A household meal-planning system that turns pantry photos, order history, preferences, and group decisions into useful state.", + href: "https://github.com/f0rr0/zeroclaw/pull/8", + importance: "story", + label: "View the ZeroClaw work", + title: "Teaching an agent what is for lunch", + }, + { + bucket: "Open source", + date: "2026-07-28", + description: + "Returning to a long-lived React Native component to keep a small public primitive useful and dependable.", + href: "https://github.com/f0rr0/react-native-rating", + importance: "brief", + label: "View react-native-rating", + title: "Maintaining the work that lasts", + }, + { + bucket: "Product systems", + date: "2026-02-01", + description: + "Reworking this site as a deliberate product surface: faster navigation, clearer writing, and better structured context.", + href: "https://github.com/f0rr0/f0rr0.dev", + importance: "brief", + label: "View the site source", + title: "Turning the portfolio into a product", + }, + { + bucket: "Product systems", + date: "2025-12-22", + description: + "Exploring payment-gated routes as a compact protocol experiment, with the public implementation kept small enough to inspect.", + href: "https://github.com/f0rr0/route-402", + importance: "brief", + label: "View route-402", + title: "Experimenting at the edge of HTTP", + }, + { + bucket: "Applied AI", + date: "2025-01-01", + description: + "Taking customer workflows from discovery and evaluation through implementation and support. The activity is counted; private repository details are not requested.", + href: "/resume", + importance: "story", + label: "See my experience", + private: true, + title: "Leading applied AI at Namefi", + }, + { + bucket: "Writing", + date: "2018-12-23", + description: + "A personal archive for explaining the decisions, trade-offs, and strange systems behind the code—not just announcing the result.", + href: "/blog", + importance: "story", + label: "Read the notes", + title: "Writing the work down", + }, +] as const satisfies readonly TimelineEntry[]; + +export const projectEditorial = { + "f0rr0.dev": { + bucket: "Product systems" as const, + description: + "This site: a fast, accessible home for the work, writing, and context behind both.", + }, + oliphaunt: { + bucket: "Open source" as const, + description: + "Embedded PostgreSQL for apps and tests, packaged to feel as simple as SQLite.", + }, + "pg-browser-proxy": { + bucket: "Product systems" as const, + description: + "A small bridge that lets desktop database clients inspect Postgres running inside a browser.", + }, + "react-native-rating": { + bucket: "Open source" as const, + description: + "An accessible, native-driver rating component that has quietly served React Native apps for years.", + }, +} as const; + +export const featuredProjectNames = [ + "oliphaunt", + "react-native-rating", + "f0rr0.dev", + "pg-browser-proxy", +] as const; diff --git a/src/db/client.ts b/src/db/client.ts new file mode 100644 index 0000000..6fa8e37 --- /dev/null +++ b/src/db/client.ts @@ -0,0 +1,71 @@ +import { neon } from "@neondatabase/serverless"; +import { drizzle as drizzleNeon } from "drizzle-orm/neon-http"; +import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +import * as schema from "@/db/schema"; + +export class TimelineDatabaseConfigurationError extends Error { + constructor() { + super("DATABASE_URL is not configured for the timeline store."); + this.name = "TimelineDatabaseConfigurationError"; + } +} + +type TimelineDatabase = PostgresJsDatabase; + +let database: TimelineDatabase | null = null; +let localClient: ReturnType | null = null; + +const readDatabaseUrl = () => { + const value = process.env.DATABASE_URL?.trim(); + return value === undefined || value.length === 0 ? null : value; +}; + +const isLocalDatabaseUrl = (databaseUrl: string) => { + try { + const { hostname } = new URL(databaseUrl); + return ( + hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost" + ); + } catch { + return false; + } +}; + +const createDatabase = (): TimelineDatabase => { + const databaseUrl = readDatabaseUrl(); + if (databaseUrl === null) { + throw new TimelineDatabaseConfigurationError(); + } + + if (isLocalDatabaseUrl(databaseUrl)) { + localClient = postgres(databaseUrl, { max: 1, prepare: false }); + return drizzlePostgres({ + client: localClient, + schema, + }); + } + + return drizzleNeon({ + client: neon(databaseUrl), + schema, + }) as unknown as TimelineDatabase; +}; + +export const isTimelineDatabaseConfigured = () => readDatabaseUrl() !== null; + +export const getTimelineDatabase = () => { + database ??= createDatabase(); + return database; +}; + +export const closeTimelineDatabase = async () => { + const client = localClient; + database = null; + localClient = null; + if (client !== null) { + await client.end({ timeout: 5 }); + } +}; diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..5187c02 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,299 @@ +import { sql } from "drizzle-orm"; +import { + boolean, + check, + date, + index, + integer, + jsonb, + pgEnum, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +import type { TimelineEdition } from "@/lib/timeline-core"; + +export const timelineVisibility = pgEnum("timeline_visibility", [ + "public", + "private", +]); + +export const timelineEditionStatus = pgEnum("timeline_edition_status", [ + "draft", + "published", + "rejected", +]); + +export const timelineSyncStatus = pgEnum("timeline_sync_status", [ + "running", + "completed", + "failed", +]); + +export const timelinePublicEventKinds = [ + "issue_opened", + "pull_request_opened", + "pull_request_reviewed", + "repository_created", +] as const; + +export type TimelinePublicEventKind = (typeof timelinePublicEventKinds)[number]; + +export const timelinePublicEventKind = pgEnum( + "timeline_public_event_kind", + timelinePublicEventKinds +); + +export const timelineActivityDays = pgTable( + "timeline_activity_days", + { + bucket: varchar("bucket", { length: 32 }).notNull(), + commitCount: integer("commit_count").notNull(), + day: date("day", { mode: "string" }).notNull(), + id: varchar("id", { length: 64 }).primaryKey(), + languageFamily: varchar("language_family", { length: 32 }).notNull(), + privacyDomainKey: varchar("privacy_domain_key", { length: 64 }), + privacyPolicyVersion: varchar("privacy_policy_version", { length: 64 }), + publicRepoName: varchar("public_repo_name", { length: 200 }), + publicRepoUrl: text("public_repo_url"), + reachedDefaultBranch: boolean("reached_default_branch") + .default(true) + .notNull(), + repoKey: varchar("repo_key", { length: 64 }).notNull(), + source: varchar("source", { length: 32 }) + .default("github-profile") + .notNull(), + subject: varchar("subject", { length: 39 }).notNull(), + updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true }) + .defaultNow() + .notNull(), + visibility: timelineVisibility("visibility").notNull(), + }, + (table) => [ + index("timeline_activity_day_idx").on(table.subject, table.day), + index("timeline_activity_visibility_idx").on( + table.subject, + table.visibility, + table.day + ), + uniqueIndex("timeline_activity_repo_day_source_idx").on( + table.subject, + table.repoKey, + table.day, + table.source + ), + check("timeline_activity_positive_count", sql`${table.commitCount} > 0`), + check( + "timeline_activity_visibility_boundary", + sql`( + ${table.visibility} = 'private' + AND ${table.publicRepoName} IS NULL + AND ${table.publicRepoUrl} IS NULL + AND ${table.privacyPolicyVersion} IS NOT NULL + ) OR ( + ${table.visibility} = 'public' + AND ${table.publicRepoName} IS NOT NULL + AND ${table.publicRepoUrl} IS NOT NULL + AND ${table.privacyDomainKey} IS NULL + AND ${table.privacyPolicyVersion} IS NULL + )` + ), + ] +); + +export const timelineContributionTotals = pgTable( + "timeline_contribution_totals", + { + contributionCount: integer("contribution_count").notNull(), + day: date("day", { mode: "string" }).notNull(), + id: varchar("id", { length: 64 }).primaryKey(), + source: varchar("source", { length: 32 }) + .default("github-public-calendar") + .notNull(), + subject: varchar("subject", { length: 39 }).notNull(), + updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + index("timeline_contribution_total_day_idx").on(table.subject, table.day), + uniqueIndex("timeline_contribution_total_day_source_idx").on( + table.subject, + table.day, + table.source + ), + check( + "timeline_contribution_total_nonnegative_count", + sql`${table.contributionCount} >= 0` + ), + check( + "timeline_contribution_total_identity_shape", + sql`${table.id} ~ '^[a-f0-9]{64}$' + AND ${table.subject} ~ '^[A-Za-z0-9][A-Za-z0-9-]{0,38}$' + AND ${table.subject} !~ '--' + AND ${table.subject} !~ '-$' + AND ${table.source} = 'github-public-calendar'` + ), + ] +); + +export const timelinePublicEvents = pgTable( + "timeline_public_events", + { + bucket: varchar("bucket", { length: 32 }).notNull(), + day: date("day", { mode: "string" }).notNull(), + eventKind: timelinePublicEventKind("event_kind").notNull(), + id: varchar("id", { length: 64 }).primaryKey(), + publicRepoName: varchar("public_repo_name", { length: 200 }).notNull(), + publicRepoUrl: text("public_repo_url").notNull(), + publicTitle: varchar("public_title", { length: 300 }).notNull(), + publicUrl: text("public_url").notNull(), + repoKey: varchar("repo_key", { length: 64 }).notNull(), + source: varchar("source", { length: 32 }) + .default("github-profile") + .notNull(), + subject: varchar("subject", { length: 39 }).notNull(), + updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + index("timeline_public_event_day_idx").on(table.subject, table.day), + index("timeline_public_event_repo_day_idx").on( + table.subject, + table.repoKey, + table.day + ), + index("timeline_public_event_kind_day_idx").on( + table.subject, + table.eventKind, + table.day + ), + check( + "timeline_public_event_identity_shape", + sql`${table.id} ~ '^[a-f0-9]{64}$' + AND ${table.repoKey} ~ '^[a-f0-9]{64}$' + AND ${table.subject} ~ '^[A-Za-z0-9][A-Za-z0-9-]{0,38}$' + AND ${table.subject} !~ '--' + AND ${table.subject} !~ '-$' + AND ${table.publicRepoName} ~ '^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/[A-Za-z0-9._-]{1,100}$'` + ), + check( + "timeline_public_event_public_boundary", + sql`${table.publicRepoUrl} = 'https://github.com/' || ${table.publicRepoName} + AND length(${table.publicUrl}) <= 500 + AND ${table.publicTitle} = btrim(${table.publicTitle}) + AND length(${table.publicTitle}) > 0 + AND ${table.publicTitle} !~ '[[:cntrl:]]' + AND ( + (${table.eventKind} = 'issue_opened' + AND substring(${table.publicUrl} from length(${table.publicRepoUrl}) + 1) ~ '^/issues/[0-9]+$') + OR (${table.eventKind} IN ('pull_request_opened', 'pull_request_reviewed') + AND substring(${table.publicUrl} from length(${table.publicRepoUrl}) + 1) ~ '^/pull/[0-9]+$') + OR (${table.eventKind} = 'repository_created' + AND ${table.publicUrl} = ${table.publicRepoUrl}) + )` + ), + check( + "timeline_public_event_bucket_boundary", + sql`${table.bucket} IN ( + 'Applied AI', + 'Open source', + 'Product systems', + 'Infrastructure', + 'Writing' + )` + ), + check( + "timeline_public_event_source_boundary", + sql`${table.source} = 'github-profile'` + ), + ] +); + +export const timelineEditions = pgTable( + "timeline_editions", + { + agentModel: varchar("agent_model", { length: 100 }).notNull(), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true }) + .defaultNow() + .notNull(), + edition: jsonb("edition").$type().notNull(), + editionKey: varchar("edition_key", { length: 64 }).notNull(), + id: uuid("id").defaultRandom().primaryKey(), + publishedAt: timestamp("published_at", { + mode: "date", + withTimezone: true, + }), + privacyPolicyVersion: varchar("privacy_policy_version", { length: 64 }), + status: timelineEditionStatus("status").default("draft").notNull(), + updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true }) + .defaultNow() + .notNull(), + windowEnd: date("window_end", { mode: "string" }).notNull(), + windowStart: date("window_start", { mode: "string" }).notNull(), + }, + (table) => [ + uniqueIndex("timeline_edition_key_idx").on(table.editionKey), + index("timeline_edition_published_idx").on(table.status, table.publishedAt), + ] +); + +export const timelineSyncRuns = pgTable( + "timeline_sync_runs", + { + completedAt: timestamp("completed_at", { + mode: "date", + withTimezone: true, + }), + coverage: varchar("coverage", { length: 16 }).default("partial").notNull(), + errorCode: varchar("error_code", { length: 64 }), + eventCount: integer("event_count").default(0).notNull(), + anonymousDayCount: integer("anonymous_day_count").default(0).notNull(), + anonymousCoverage: varchar("anonymous_coverage", { length: 16 }) + .default("unavailable") + .notNull(), + id: uuid("id").defaultRandom().primaryKey(), + fullWindow: boolean("full_window").default(false).notNull(), + kind: varchar("kind", { length: 32 }).notNull(), + publicEventCoverage: varchar("public_event_coverage", { length: 16 }) + .default("unavailable") + .notNull(), + rowCount: integer("row_count").default(0).notNull(), + startedAt: timestamp("started_at", { mode: "date", withTimezone: true }) + .defaultNow() + .notNull(), + status: timelineSyncStatus("status").default("running").notNull(), + windowEnd: date("window_end", { mode: "string" }).notNull(), + windowStart: date("window_start", { mode: "string" }).notNull(), + }, + (table) => [index("timeline_sync_started_idx").on(table.startedAt)] +); + +export const timelineWebhookReceipts = pgTable( + "timeline_webhook_receipts", + { + deliveryKey: varchar("delivery_key", { length: 64 }).primaryKey(), + eventType: varchar("event_type", { length: 40 }).notNull(), + expiresAt: timestamp("expires_at", { + mode: "date", + withTimezone: true, + }).notNull(), + processedAt: timestamp("processed_at", { + mode: "date", + withTimezone: true, + }), + receivedAt: timestamp("received_at", { + mode: "date", + withTimezone: true, + }) + .defaultNow() + .notNull(), + status: varchar("status", { length: 24 }).notNull(), + }, + (table) => [index("timeline_webhook_expiry_idx").on(table.expiresAt)] +); diff --git a/src/env.ts b/src/env.ts index 5f75de3..fe82fcd 100644 --- a/src/env.ts +++ b/src/env.ts @@ -10,9 +10,25 @@ export const env = createEnv({ NEXT_PUBLIC_PORT: process.env.NEXT_PUBLIC_PORT, }, server: { + AI_GATEWAY_API_KEY: z.string().min(1).optional(), + AI_GATEWAY_ZERO_DATA_RETENTION: z.enum(["true", "false"]).optional(), + CRON_SECRET: z.string().min(16).optional(), + DATABASE_URL: z.url().optional(), + DATABASE_URL_UNPOOLED: z.url().optional(), + GH_TOKEN: z.string().min(1).optional(), + GITHUB_ACTIVITY_TOKEN: z.string().min(1).optional(), + GITHUB_PUBLIC_ACTIVITY_TOKEN: z.string().min(1).optional(), + GITHUB_APP_ID: z.string().min(1).optional(), + GITHUB_APP_INSTALLATION_IDS: z.string().min(1).optional(), + GITHUB_APP_PRIVATE_KEY: z.string().min(1).optional(), + GITHUB_TOKEN: z.string().min(1).optional(), + GITHUB_WEBHOOK_SECRET: z.string().min(16).optional(), NODE_ENV: z.enum(["development", "production", "test"]).optional(), + OPENAI_API_KEY: z.string().min(1).optional(), PORT: z.string().min(1).optional(), SITE_URL: z.string().min(1).optional(), + TIMELINE_PRIVATE_TAXONOMY: z.string().min(2).optional(), + TIMELINE_PRIVACY_KEY: z.string().min(32).optional(), VERCEL_ENV: z.enum(["development", "preview", "production"]).optional(), VERCEL_PROJECT_PRODUCTION_URL: z.string().min(1).optional(), VERCEL_URL: z.string().min(1).optional(), diff --git a/src/lib/github-contribution-calendar.ts b/src/lib/github-contribution-calendar.ts new file mode 100644 index 0000000..bbf46e2 --- /dev/null +++ b/src/lib/github-contribution-calendar.ts @@ -0,0 +1,85 @@ +import { setTimeout as delay } from "node:timers/promises"; + +import { parseGitHubContributionCalendarDays } from "@/lib/github-profile-core"; +import type { GitHubContributionDay } from "@/lib/github-profile-core"; + +const GITHUB_FETCH_TIMEOUT_MS = 10_000; +const GITHUB_FETCH_ATTEMPTS = 3; +const GITHUB_LOGIN_PATTERN = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +const fetchContributionCalendar = async (login: string, year: number) => { + const url = new URL(`https://github.com/users/${login}/contributions`); + url.searchParams.set("from", `${year}-01-01`); + url.searchParams.set("to", `${year}-12-31`); + + for (let attempt = 0; attempt < GITHUB_FETCH_ATTEMPTS; attempt += 1) { + let response: Response; + try { + response = await fetch(url, { + cache: "no-store", + headers: { + Accept: "text/html", + "Accept-Language": "en-US,en;q=0.9", + "User-Agent": "f0rr0.dev", + }, + signal: AbortSignal.timeout(GITHUB_FETCH_TIMEOUT_MS), + }); + } catch (error) { + if (attempt === GITHUB_FETCH_ATTEMPTS - 1) { + throw error; + } + await delay(200 * 2 ** attempt); + continue; + } + + if (response.ok) { + return await response.text(); + } + if (response.status < 500 && response.status !== 429) { + throw new Error(`GitHub returned HTTP ${response.status}.`); + } + if (attempt === GITHUB_FETCH_ATTEMPTS - 1) { + throw new Error(`GitHub returned HTTP ${response.status}.`); + } + await delay(200 * 2 ** attempt); + } + + throw new Error("GitHub contribution-calendar retry budget exhausted."); +}; + +export const fetchPublicGitHubContributionDays = async (input: { + login: string; + windowEnd: string; + windowStart: string; +}): Promise => { + if ( + !GITHUB_LOGIN_PATTERN.test(input.login) || + !DATE_PATTERN.test(input.windowStart) || + !DATE_PATTERN.test(input.windowEnd) || + input.windowStart > input.windowEnd + ) { + throw new Error("Invalid GitHub contribution-calendar window."); + } + + const years = [ + ...new Set([ + Number(input.windowStart.slice(0, 4)), + Number(input.windowEnd.slice(0, 4)), + ]), + ]; + const htmlDocuments = await Promise.all( + years.map( + async (year) => await fetchContributionCalendar(input.login, year) + ) + ); + const days = parseGitHubContributionCalendarDays(htmlDocuments, { + from: `${input.windowStart}T00:00:00.000Z`, + to: `${input.windowEnd}T23:59:59.999Z`, + }); + + if (days === null) { + throw new Error("GitHub returned an invalid contribution calendar."); + } + return days; +}; diff --git a/src/lib/github-profile-core.ts b/src/lib/github-profile-core.ts new file mode 100644 index 0000000..f24ed6c --- /dev/null +++ b/src/lib/github-profile-core.ts @@ -0,0 +1,837 @@ +const DAY_IN_MILLISECONDS = 86_400_000; +const CONTRIBUTION_WINDOW_DAYS = 365; +const MAX_PROJECTS = 100; +const MAX_ACTIVITY_WINDOW_DAYS = 400; +const MAX_CONTRIBUTION_HTML_LENGTH = 2_000_000; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const GITHUB_LOGIN_PATTERN = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i; +const REPOSITORY_NAME_PATTERN = /^[a-z\d._-]{1,100}$/i; +const LANGUAGE_COLOR_PATTERN = /^#[\da-f]{6}$/i; + +type JsonObject = Record; + +export type GitHubActivityLevel = 0 | 1 | 2 | 3 | 4; +export type GitHubDataStatus = "available" | "unavailable"; + +export interface GitHubContributionWindow { + from: string; + to: string; +} + +export interface GitHubActivityWeek { + contributionCount: number; + level: GitHubActivityLevel; + weekStart: string; +} + +export interface GitHubContributionDay { + contributionCount: number; + day: string; +} + +export interface AvailableGitHubActivity { + activeDays: number; + from: string; + restrictedContributions: number | null; + status: "available"; + to: string; + totalContributions: number; + weeks: GitHubActivityWeek[]; +} + +export interface UnavailableGitHubActivity { + activeDays: null; + from: string; + restrictedContributions: null; + status: "unavailable"; + to: string; + totalContributions: null; + weeks: []; +} + +export type GitHubActivity = + | AvailableGitHubActivity + | UnavailableGitHubActivity; + +export interface GitHubProject { + description: string | null; + forks: number | null; + language: string | null; + languageColor: string | null; + name: string; + stars: number | null; + topics: string[]; + updatedAt: string | null; + url: string; +} + +export interface GitHubProfile { + activity: GitHubActivity; + fetchedAt: string | null; + login: string; + profileUrl: string; + projects: GitHubProject[]; + status: GitHubDataStatus; +} + +export interface ParseGitHubProfileOptions { + fetchedAt: string; + login: string; + window: GitHubContributionWindow; +} + +export interface GitHubFallbackOptions { + login: string; + window: GitHubContributionWindow; +} + +const curatedProjectFallback = [ + { + description: + "Rust library for running embedded PostgreSQL inside applications and tests.", + forks: null, + language: "Rust", + languageColor: "#dea584", + name: "oliphaunt", + stars: null, + topics: ["postgresql", "rust", "testing"], + updatedAt: null, + }, + { + description: + "Cross-platform React Native rating component built with Animated and the native driver.", + forks: null, + language: "JavaScript", + languageColor: "#f1e05a", + name: "react-native-rating", + stars: null, + topics: ["react-native", "animation", "component"], + updatedAt: null, + }, +] as const; + +const isObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +const asNonNegativeInteger = (value: unknown) => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0 + ? value + : null; + +const normalizeDate = (value: unknown) => { + if (typeof value !== "string" || !DATE_PATTERN.test(value)) { + return null; + } + + const date = new Date(`${value}T00:00:00.000Z`); + return Number.isNaN(date.getTime()) || + date.toISOString().slice(0, 10) !== value + ? null + : value; +}; + +const normalizeDateTime = (value: unknown) => { + if (typeof value !== "string") { + return null; + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +}; + +const dateOnly = (value: string) => + normalizeDateTime(value)?.slice(0, 10) ?? null; + +const normalizeOptionalText = (value: unknown, maximumLength: number) => { + if (typeof value !== "string") { + return null; + } + + const normalized = value.replaceAll(/\s+/g, " ").trim(); + if (normalized.length === 0) { + return null; + } + + return normalized.slice(0, maximumLength); +}; + +const normalizeLogin = (value: unknown) => { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + return GITHUB_LOGIN_PATTERN.test(normalized) ? normalized : null; +}; + +const normalizeRepositoryName = (value: unknown) => { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + return REPOSITORY_NAME_PATTERN.test(normalized) && + normalized !== "." && + normalized !== ".." + ? normalized + : null; +}; + +const isExpectedRepositoryUrl = ( + value: unknown, + login: string, + name: string +) => { + if (typeof value !== "string") { + return false; + } + + try { + const url = new URL(value); + const segments = url.pathname.split("/").filter(Boolean); + + return ( + url.protocol === "https:" && + url.hostname === "github.com" && + url.search === "" && + url.hash === "" && + segments.length === 2 && + segments[0]?.toLowerCase() === login.toLowerCase() && + segments[1]?.toLowerCase() === name.toLowerCase() + ); + } catch { + return false; + } +}; + +const normalizeTopics = (value: unknown) => { + if (!isObject(value) || !Array.isArray(value.nodes)) { + return []; + } + + const topics = new Set(); + + for (const node of value.nodes) { + if (!isObject(node) || !isObject(node.topic)) { + continue; + } + + const topic = normalizeOptionalText(node.topic.name, 50)?.toLowerCase(); + if ( + topic !== undefined && + topic !== null && + /^[a-z\d][a-z\d-]*$/.test(topic) + ) { + topics.add(topic); + } + + if (topics.size === 5) { + break; + } + } + + return [...topics]; +}; + +const normalizeTopicNames = (value: unknown) => { + if (!Array.isArray(value)) { + return []; + } + + const topics = new Set(); + + for (const rawTopic of value) { + const topic = normalizeOptionalText(rawTopic, 50)?.toLowerCase(); + if ( + topic !== undefined && + topic !== null && + /^[a-z\d][a-z\d-]*$/.test(topic) + ) { + topics.add(topic); + } + + if (topics.size === 5) { + break; + } + } + + return [...topics]; +}; + +const normalizeLanguage = (value: unknown) => { + if (!isObject(value)) { + return { color: null, name: null }; + } + + const name = normalizeOptionalText(value.name, 50); + const color = + typeof value.color === "string" && LANGUAGE_COLOR_PATTERN.test(value.color) + ? value.color.toLowerCase() + : null; + + return { color, name }; +}; + +const normalizeProject = ( + value: unknown, + expectedLogin: string +): GitHubProject | null => { + if ( + !isObject(value) || + value.isPrivate !== false || + value.isFork !== false || + !isObject(value.owner) + ) { + return null; + } + + const ownerLogin = normalizeLogin(value.owner.login); + const name = normalizeRepositoryName(value.name); + const stars = asNonNegativeInteger(value.stargazerCount); + const forks = asNonNegativeInteger(value.forkCount); + + if ( + ownerLogin === null || + ownerLogin.toLowerCase() !== expectedLogin.toLowerCase() || + name === null || + stars === null || + forks === null || + !isExpectedRepositoryUrl(value.url, expectedLogin, name) + ) { + return null; + } + + const language = normalizeLanguage(value.primaryLanguage); + + return { + description: normalizeOptionalText(value.description, 240), + forks, + language: language.name, + languageColor: language.color, + name, + stars, + topics: normalizeTopics(value.repositoryTopics), + updatedAt: normalizeDateTime(value.updatedAt), + url: `https://github.com/${expectedLogin}/${name}`, + }; +}; + +const normalizeRestProject = ( + value: unknown, + expectedLogin: string +): GitHubProject | null => { + if ( + !isObject(value) || + value.private !== false || + value.fork !== false || + !isObject(value.owner) + ) { + return null; + } + + const ownerLogin = normalizeLogin(value.owner.login); + const name = normalizeRepositoryName(value.name); + const stars = asNonNegativeInteger(value.stargazers_count); + const forks = asNonNegativeInteger(value.forks_count); + + if ( + ownerLogin === null || + ownerLogin.toLowerCase() !== expectedLogin.toLowerCase() || + name === null || + stars === null || + forks === null || + !isExpectedRepositoryUrl(value.html_url, expectedLogin, name) + ) { + return null; + } + + return { + description: normalizeOptionalText(value.description, 240), + forks, + language: normalizeOptionalText(value.language, 50), + languageColor: null, + name, + stars, + topics: normalizeTopicNames(value.topics), + updatedAt: normalizeDateTime(value.updated_at), + url: `https://github.com/${expectedLogin}/${name}`, + }; +}; + +const sortAndLimitProjects = (projects: GitHubProject[]) => + projects + .toSorted( + (left, right) => + (right.stars ?? 0) - (left.stars ?? 0) || + (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "") + ) + .slice(0, MAX_PROJECTS); + +const activityLevel = ( + contributionCount: number, + maximumContributionCount: number +): GitHubActivityLevel => { + if (contributionCount === 0 || maximumContributionCount === 0) { + return 0; + } + + return Math.min( + 4, + Math.max(1, Math.ceil((contributionCount / maximumContributionCount) * 4)) + ) as GitHubActivityLevel; +}; + +const buildActivityWeeks = (weeklyCounts: Map) => { + const maximumContributionCount = Math.max(0, ...weeklyCounts.values()); + + return [...weeklyCounts] + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([weekStart, contributionCount]) => ({ + contributionCount, + level: activityLevel(contributionCount, maximumContributionCount), + weekStart, + })); +}; + +const normalizeWeeks = ( + value: unknown, + from: string, + to: string +): { activeDays: number; weeks: GitHubActivityWeek[] } | null => { + if (!Array.isArray(value)) { + return null; + } + + const activeDates = new Set(); + const seenDates = new Set(); + const weeklyCounts = new Map(); + + for (const rawWeek of value) { + if (!isObject(rawWeek) || !Array.isArray(rawWeek.contributionDays)) { + return null; + } + + const weekStart = normalizeDate(rawWeek.firstDay); + if (weekStart === null) { + return null; + } + + let contributionCount = weeklyCounts.get(weekStart) ?? 0; + + for (const rawDay of rawWeek.contributionDays) { + if (!isObject(rawDay)) { + return null; + } + + const date = normalizeDate(rawDay.date); + const count = asNonNegativeInteger(rawDay.contributionCount); + + if (date === null || count === null) { + return null; + } + + if (date < from || date > to || seenDates.has(date)) { + continue; + } + + seenDates.add(date); + contributionCount += count; + + if (count > 0) { + activeDates.add(date); + } + } + + weeklyCounts.set(weekStart, contributionCount); + } + + return { + activeDays: activeDates.size, + weeks: buildActivityWeeks(weeklyCounts), + }; +}; + +const readHtmlAttributes = (source: string) => { + const attributes = new Map(); + const attributePattern = /([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; + + for (const match of source.matchAll(attributePattern)) { + const name = match[1]?.toLowerCase(); + const value = match[2] ?? match[3]; + if (name !== undefined && value !== undefined) { + attributes.set(name, value); + } + } + + return attributes; +}; + +const contributionCountFromTooltip = (source: string) => { + const text = source + .replaceAll(/<[^>]*>/g, " ") + .replaceAll(/ /gi, " ") + .replaceAll(/\s+/g, " ") + .trim(); + + if (/^No contributions?\b/i.test(text)) { + return 0; + } + + const count = /^([\d,]+)\s+contributions?\b/i.exec(text)?.[1]; + return count === undefined + ? null + : asNonNegativeInteger(Number(count.replaceAll(",", ""))); +}; + +const readTooltipCounts = (html: string) => { + const counts = new Map(); + const tooltipPattern = /]*)>([\s\S]*?)<\/tool-tip>/gi; + + for (const match of html.matchAll(tooltipPattern)) { + const attributes = readHtmlAttributes(match[1] ?? ""); + const target = attributes.get("for"); + if (target?.startsWith("contribution-day-component-") !== true) { + continue; + } + + const count = contributionCountFromTooltip(match[2] ?? ""); + if ( + count === null || + (counts.has(target) && counts.get(target) !== count) + ) { + return null; + } + + counts.set(target, count); + } + + return counts; +}; + +const parseContributionCalendarDocument = (html: string) => { + if (html.length === 0 || html.length > MAX_CONTRIBUTION_HTML_LENGTH) { + return null; + } + + const tooltipCounts = readTooltipCounts(html); + if (tooltipCounts === null) { + return null; + } + + const days = new Map(); + const dayCellPattern = /]*)>/gi; + + for (const match of html.matchAll(dayCellPattern)) { + const attributes = readHtmlAttributes(match[1] ?? ""); + const classes = attributes.get("class")?.split(/\s+/) ?? []; + if (!classes.includes("ContributionCalendar-day")) { + continue; + } + + const date = normalizeDate(attributes.get("data-date")); + const target = attributes.get("id"); + const count = target === undefined ? undefined : tooltipCounts.get(target); + + if ( + date === null || + count === undefined || + (days.has(date) && days.get(date) !== count) + ) { + return null; + } + + days.set(date, count); + } + + return days.size === 0 ? null : days; +}; + +const startOfContributionWeek = (date: string) => { + const value = new Date(`${date}T00:00:00.000Z`); + value.setUTCDate(value.getUTCDate() - value.getUTCDay()); + return value.toISOString().slice(0, 10); +}; + +const summarizeContributionDays = ( + days: Map, + from: string, + to: string +): AvailableGitHubActivity | null => { + const fromTime = new Date(`${from}T00:00:00.000Z`).getTime(); + const toTime = new Date(`${to}T00:00:00.000Z`).getTime(); + const dayCount = Math.floor((toTime - fromTime) / DAY_IN_MILLISECONDS) + 1; + + if (dayCount < 1 || dayCount > MAX_ACTIVITY_WINDOW_DAYS) { + return null; + } + + let activeDays = 0; + let totalContributions = 0; + const weeklyCounts = new Map(); + + for (let offset = 0; offset < dayCount; offset += 1) { + const date = new Date(fromTime + offset * DAY_IN_MILLISECONDS) + .toISOString() + .slice(0, 10); + const count = days.get(date); + if (count === undefined) { + return null; + } + + totalContributions += count; + if (!Number.isSafeInteger(totalContributions)) { + return null; + } + if (count > 0) { + activeDays += 1; + } + + const weekStart = startOfContributionWeek(date); + weeklyCounts.set(weekStart, (weeklyCounts.get(weekStart) ?? 0) + count); + } + + return { + activeDays, + from, + restrictedContributions: null, + status: "available", + to, + totalContributions, + weeks: buildActivityWeeks(weeklyCounts), + }; +}; + +const mergeContributionDays = ( + target: Map, + source: Map +) => { + for (const [date, count] of source) { + if (target.has(date) && target.get(date) !== count) { + return false; + } + target.set(date, count); + } + + return true; +}; + +const readGraphQlUser = (value: unknown) => { + if (!isObject(value)) { + return null; + } + if (Array.isArray(value.errors) && value.errors.length > 0) { + return null; + } + if (!isObject(value.data) || !isObject(value.data.user)) { + return null; + } + + return value.data.user; +}; + +const isGitHubProfileUser = (user: JsonObject, expectedLogin: string) => { + const returnedLogin = normalizeLogin(user.login); + + return ( + returnedLogin !== null && + returnedLogin.toLowerCase() === expectedLogin.toLowerCase() && + isObject(user.contributionsCollection) && + isObject(user.contributionsCollection.contributionCalendar) && + isObject(user.repositories) && + Array.isArray(user.repositories.nodes) + ); +}; + +export const createGitHubContributionWindow = ( + now = new Date() +): GitHubContributionWindow => { + if (Number.isNaN(now.getTime())) { + throw new RangeError( + "A valid date is required for the GitHub activity window." + ); + } + + const from = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - + (CONTRIBUTION_WINDOW_DAYS - 1) * DAY_IN_MILLISECONDS + ); + + return { from: from.toISOString(), to: now.toISOString() }; +}; + +export const parseGitHubContributionCalendarDays = ( + htmlDocuments: readonly string[], + window: GitHubContributionWindow +): GitHubContributionDay[] | null => { + const from = dateOnly(window.from); + const to = dateOnly(window.to); + + if ( + from === null || + to === null || + from > to || + !Array.isArray(htmlDocuments) || + htmlDocuments.length === 0 + ) { + return null; + } + + const days = new Map(); + + for (const html of htmlDocuments) { + if (typeof html !== "string") { + return null; + } + + const parsedDays = parseContributionCalendarDocument(html); + if (parsedDays === null || !mergeContributionDays(days, parsedDays)) { + return null; + } + } + + if (summarizeContributionDays(days, from, to) === null) { + return null; + } + + return [...days] + .filter(([day]) => day >= from && day <= to) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([day, contributionCount]) => ({ contributionCount, day })); +}; + +export const parseGitHubContributionCalendarHtml = ( + htmlDocuments: readonly string[], + window: GitHubContributionWindow +): AvailableGitHubActivity | null => { + const contributionDays = parseGitHubContributionCalendarDays( + htmlDocuments, + window + ); + if (contributionDays === null) { + return null; + } + + return summarizeContributionDays( + new Map( + contributionDays.map(({ contributionCount, day }) => [ + day, + contributionCount, + ]) + ), + dateOnly(window.from) ?? "", + dateOnly(window.to) ?? "" + ); +}; + +export const parseGitHubRepositoriesResponse = ( + value: unknown, + login: string +): GitHubProject[] | null => { + const expectedLogin = normalizeLogin(login); + if (expectedLogin === null || !Array.isArray(value)) { + return null; + } + + const projects = value + .map((project) => normalizeRestProject(project, expectedLogin)) + .filter((project): project is GitHubProject => project !== null); + + return projects.length === 0 ? null : sortAndLimitProjects(projects); +}; + +export const createUnavailableGitHubProfile = ({ + login, + window, +}: GitHubFallbackOptions): GitHubProfile => { + const normalizedLogin = normalizeLogin(login) ?? "f0rr0"; + const from = dateOnly(window.from) ?? ""; + const to = dateOnly(window.to) ?? ""; + + return { + activity: { + activeDays: null, + from, + restrictedContributions: null, + status: "unavailable", + to, + totalContributions: null, + weeks: [], + }, + fetchedAt: null, + login: normalizedLogin, + profileUrl: `https://github.com/${normalizedLogin}`, + projects: curatedProjectFallback.map((project) => ({ + ...project, + topics: [...project.topics], + url: `https://github.com/${normalizedLogin}/${project.name}`, + })), + status: "unavailable", + }; +}; + +export const parseGitHubProfileResponse = ( + value: unknown, + { fetchedAt, login, window }: ParseGitHubProfileOptions +): GitHubProfile | null => { + const expectedLogin = normalizeLogin(login); + const normalizedFetchedAt = normalizeDateTime(fetchedAt); + const from = dateOnly(window.from); + const to = dateOnly(window.to); + const user = readGraphQlUser(value); + + if ( + expectedLogin === null || + normalizedFetchedAt === null || + from === null || + to === null || + from > to || + user === null || + !isGitHubProfileUser(user, expectedLogin) + ) { + return null; + } + + const collection = user.contributionsCollection as JsonObject; + const calendar = collection.contributionCalendar as JsonObject; + const totalContributions = asNonNegativeInteger(calendar.totalContributions); + const restrictedContributions = asNonNegativeInteger( + collection.restrictedContributionsCount + ); + const normalizedWeeks = normalizeWeeks(calendar.weeks, from, to); + const normalizedTotal = normalizedWeeks?.weeks.reduce( + (total, week) => total + week.contributionCount, + 0 + ); + + if ( + totalContributions === null || + restrictedContributions === null || + restrictedContributions > totalContributions || + normalizedWeeks === null || + normalizedTotal !== totalContributions + ) { + return null; + } + + const repositories = user.repositories as JsonObject; + const projects = (repositories.nodes as unknown[]) + .map((project) => normalizeProject(project, expectedLogin)) + .filter((project): project is GitHubProject => project !== null); + + return { + activity: { + activeDays: normalizedWeeks.activeDays, + from, + restrictedContributions, + status: "available", + to, + totalContributions, + weeks: normalizedWeeks.weeks, + }, + fetchedAt: normalizedFetchedAt, + login: expectedLogin, + profileUrl: `https://github.com/${expectedLogin}`, + projects: sortAndLimitProjects(projects), + status: "available", + }; +}; diff --git a/src/lib/github-profile.ts b/src/lib/github-profile.ts new file mode 100644 index 0000000..edb2be2 --- /dev/null +++ b/src/lib/github-profile.ts @@ -0,0 +1,295 @@ +import "server-only"; +import { setTimeout as delay } from "node:timers/promises"; + +import { unstable_cache } from "next/cache"; + +import { + createGitHubContributionWindow, + createUnavailableGitHubProfile, + parseGitHubContributionCalendarHtml, + parseGitHubProfileResponse, + parseGitHubRepositoriesResponse, +} from "@/lib/github-profile-core"; +import type { GitHubProfile } from "@/lib/github-profile-core"; + +export type { + AvailableGitHubActivity, + GitHubActivity, + GitHubActivityLevel, + GitHubActivityWeek, + GitHubContributionDay, + GitHubContributionWindow, + GitHubDataStatus, + GitHubProfile, + GitHubProject, + UnavailableGitHubActivity, +} from "@/lib/github-profile-core"; + +const GITHUB_LOGIN = "f0rr0"; +const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"; +const GITHUB_REST_URL = `https://api.github.com/users/${GITHUB_LOGIN}/repos`; +const GITHUB_CONTRIBUTIONS_URL = `https://github.com/users/${GITHUB_LOGIN}/contributions`; +const GITHUB_FETCH_TIMEOUT_MS = 10_000; +const GITHUB_FETCH_ATTEMPTS = 3; +const GITHUB_CACHE_SECONDS = 60 * 60 * 12; + +const githubProfileQuery = ` + query PortfolioGitHubProfile( + $login: String! + $from: DateTime! + $to: DateTime! + ) { + user(login: $login) { + login + contributionsCollection(from: $from, to: $to) { + restrictedContributionsCount + contributionCalendar { + totalContributions + weeks { + firstDay + contributionDays { + date + contributionCount + } + } + } + } + repositories( + first: 100 + ownerAffiliations: OWNER + privacy: PUBLIC + isFork: false + orderBy: { field: STARGAZERS, direction: DESC } + ) { + nodes { + name + description + url + isPrivate + isFork + owner { + login + } + stargazerCount + forkCount + updatedAt + primaryLanguage { + name + color + } + repositoryTopics(first: 5) { + nodes { + topic { + name + } + } + } + } + } + } + } +`; + +const readGitHubToken = () => { + for (const token of [process.env.GITHUB_TOKEN, process.env.GH_TOKEN]) { + const normalized = token?.trim(); + if (normalized !== undefined && normalized.length > 0) { + return normalized; + } + } + + return null; +}; + +const fetchGitHubResource = async (url: string, init: RequestInit = {}) => { + for (let attempt = 0; attempt < GITHUB_FETCH_ATTEMPTS; attempt += 1) { + let response: Response; + try { + response = await fetch(url, { + ...init, + cache: "no-store", + signal: AbortSignal.timeout(GITHUB_FETCH_TIMEOUT_MS), + }); + } catch (error) { + if (attempt === GITHUB_FETCH_ATTEMPTS - 1) { + throw error; + } + await delay(200 * 2 ** attempt); + continue; + } + + if (response.ok) { + return response; + } + if (response.status < 500 && response.status !== 429) { + throw new Error(`GitHub returned HTTP ${response.status}.`); + } + if (attempt === GITHUB_FETCH_ATTEMPTS - 1) { + throw new Error(`GitHub returned HTTP ${response.status}.`); + } + + await delay(200 * 2 ** attempt); + } + + throw new Error("GitHub request retry budget exhausted."); +}; + +const fetchAuthenticatedGitHubProfile = async ( + token: string, + fetchedAt: Date, + window: ReturnType +): Promise => { + const response = await fetchGitHubResource(GITHUB_GRAPHQL_URL, { + body: JSON.stringify({ + query: githubProfileQuery, + variables: { + from: window.from, + login: GITHUB_LOGIN, + to: window.to, + }, + }), + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "f0rr0.dev", + }, + method: "POST", + }); + + const payload: unknown = await response.json(); + const profile = parseGitHubProfileResponse(payload, { + fetchedAt: fetchedAt.toISOString(), + login: GITHUB_LOGIN, + window, + }); + + if (profile === null) { + throw new Error("GitHub returned an invalid profile response."); + } + + return profile; +}; + +const fetchPublicRepositories = async () => { + const url = new URL(GITHUB_REST_URL); + url.searchParams.set("direction", "desc"); + url.searchParams.set("page", "1"); + url.searchParams.set("per_page", "100"); + url.searchParams.set("sort", "updated"); + url.searchParams.set("type", "owner"); + + const response = await fetchGitHubResource(url.toString(), { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "f0rr0.dev", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + return (await response.json()) as unknown; +}; + +const fetchContributionCalendar = async (year: number) => { + const url = new URL(GITHUB_CONTRIBUTIONS_URL); + url.searchParams.set("from", `${year}-01-01`); + url.searchParams.set("to", `${year}-12-31`); + + const response = await fetchGitHubResource(url.toString(), { + headers: { + Accept: "text/html", + "Accept-Language": "en-US,en;q=0.9", + "User-Agent": "f0rr0.dev", + }, + }); + + return await response.text(); +}; + +const fetchContributionCalendars = async (years: readonly number[]) => { + const calendars: string[] = []; + for (const year of years) { + calendars.push(await fetchContributionCalendar(year)); + } + return calendars; +}; + +const fetchPublicGitHubProfile = async ( + fetchedAt: Date, + window: ReturnType +): Promise => { + const years = [ + ...new Set([ + new Date(window.from).getUTCFullYear(), + new Date(window.to).getUTCFullYear(), + ]), + ]; + const [repositoryResult] = await Promise.allSettled([ + fetchPublicRepositories(), + ]); + const [calendarResult] = await Promise.allSettled([ + fetchContributionCalendars(years), + ]); + const projects = + repositoryResult.status === "fulfilled" + ? parseGitHubRepositoriesResponse(repositoryResult.value, GITHUB_LOGIN) + : null; + const activity = + calendarResult.status === "fulfilled" + ? parseGitHubContributionCalendarHtml(calendarResult.value, window) + : null; + + if (projects === null && activity === null) { + throw new Error("GitHub returned invalid public profile data."); + } + + const unavailableProfile = createUnavailableGitHubProfile({ + login: GITHUB_LOGIN, + window, + }); + + return { + activity: activity ?? unavailableProfile.activity, + fetchedAt: fetchedAt.toISOString(), + login: GITHUB_LOGIN, + profileUrl: `https://github.com/${GITHUB_LOGIN}`, + projects: projects ?? unavailableProfile.projects, + status: projects === null ? "unavailable" : "available", + }; +}; + +const fetchGitHubProfile = async (): Promise => { + const fetchedAt = new Date(); + const window = createGitHubContributionWindow(fetchedAt); + const token = readGitHubToken(); + + if (token === null) { + return await fetchPublicGitHubProfile(fetchedAt, window); + } + + try { + return await fetchAuthenticatedGitHubProfile(token, fetchedAt, window); + } catch { + return await fetchPublicGitHubProfile(fetchedAt, window); + } +}; + +const getCachedGitHubProfile = unstable_cache( + fetchGitHubProfile, + ["portfolio-github-profile-v5", GITHUB_LOGIN], + { + revalidate: GITHUB_CACHE_SECONDS, + tags: ["github-profile"], + } +); + +export const getGitHubProfile = async (): Promise => { + try { + return await getCachedGitHubProfile(); + } catch { + return createUnavailableGitHubProfile({ + login: GITHUB_LOGIN, + window: createGitHubContributionWindow(), + }); + } +}; diff --git a/src/lib/request-auth.ts b/src/lib/request-auth.ts new file mode 100644 index 0000000..132c6ec --- /dev/null +++ b/src/lib/request-auth.ts @@ -0,0 +1,19 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + +const digest = (value: string) => + createHash("sha256").update(value, "utf-8").digest(); + +export const constantTimeEqual = (left: string, right: string) => + timingSafeEqual(digest(left), digest(right)); + +export const hasBearerSecret = ( + authorization: string | null, + secret: string | undefined +) => { + const normalizedSecret = secret?.trim(); + if (normalizedSecret === undefined || normalizedSecret.length < 16) { + return false; + } + + return constantTimeEqual(authorization ?? "", `Bearer ${normalizedSecret}`); +}; diff --git a/src/lib/timeline-core.ts b/src/lib/timeline-core.ts new file mode 100644 index 0000000..9a09f7f --- /dev/null +++ b/src/lib/timeline-core.ts @@ -0,0 +1,649 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +export const TIMELINE_SCHEMA_VERSION = 2; +export const TIMELINE_PROMPT_VERSION = "newspaper-v2"; +export const TIMELINE_WINDOW_DAYS = 400; + +const datePattern = /^\d{4}-\d{2}-\d{2}$/; +const entryIdPattern = /^[a-z0-9][a-z0-9-]{5,95}$/; +const sourceKeyPattern = /^[a-z0-9][a-z0-9:_-]{7,127}$/; + +export const workBucketSchema = z.enum([ + "Across the work", + "Applied AI", + "Open source", + "Product systems", + "Infrastructure", + "Writing", + "Private product work", +]); + +export type WorkBucket = z.infer; + +export const timelineImportanceSchema = z.enum([ + "lead", + "story", + "brief", + "pulse", +]); +export type TimelineImportance = z.infer; + +export const timelineVisibilitySchema = z.enum([ + "public", + "private", + "mixed", + "anonymous", +]); +export type TimelineVisibility = z.infer; + +export const timelineEntryKindSchema = z.enum([ + "project", + "activity", + "issue", + "pull-request", +]); +export type TimelineEntryKind = z.infer; + +export const timelineCadenceSchema = z.enum([ + "isolated", + "clustered", + "streak", +]); +export type TimelineCadence = z.infer; + +const timelineHrefSchema = z + .string() + .max(500) + .refine((value) => { + if (value.startsWith("/")) { + return !value.startsWith("//"); + } + + try { + const url = new URL(value); + return url.protocol === "https:"; + } catch { + return false; + } + }, "Timeline links must be internal paths or HTTPS URLs."); + +const privateTextPatterns = [ + /\d/, + /https?:|www\./i, + /github/i, + /@/, + /\b[\da-f]{7,40}\b/i, + /\b[A-Z]{2,10}-\d+\b/, + /\b(?:src|lib|app|packages?|services?|internal)\//i, + /\b[\w.-]+\.(?:c|cpp|go|java|js|jsx|md|py|rb|rs|sql|ts|tsx|yml|yaml)\b/i, + /\b[a-z\d._-]+\/[a-z\d._-]+\b/i, +] as const; + +const normalizedPrivateCopy = (value: string) => + value.normalize("NFKC").replaceAll(/\p{Cf}/gu, ""); + +export const containsPrivateIdentifier = (value: string) => + privateTextPatterns.some((pattern) => + pattern.test(normalizedPrivateCopy(value)) + ); + +export const timelineEditionEntrySchema = z + .object({ + bucket: workBucketSchema, + description: z.string().trim().min(20).max(360), + endDate: z.string().regex(datePattern), + href: timelineHrefSchema.optional(), + id: z.string().regex(entryIdPattern), + importance: timelineImportanceSchema, + cadence: timelineCadenceSchema, + kind: timelineEntryKindSchema, + label: z.string().trim().min(2).max(48).optional(), + metrics: z.array(z.string().trim().min(2).max(72)).max(3).default([]), + sourceKeys: z.array(z.string().regex(sourceKeyPattern)).min(1).max(12), + startDate: z.string().regex(datePattern), + title: z.string().trim().min(2).max(120), + visibility: timelineVisibilitySchema, + }) + .strict() + .superRefine((entry, context) => { + if (entry.startDate > entry.endDate) { + context.addIssue({ + code: "custom", + message: "startDate must not be after endDate.", + path: ["startDate"], + }); + } + + if (entry.href === undefined && entry.label !== undefined) { + context.addIssue({ + code: "custom", + message: "A link label requires an href.", + path: ["label"], + }); + } + + if (entry.visibility === "public") { + return; + } + + if (entry.href !== undefined || entry.label !== undefined) { + context.addIssue({ + code: "custom", + message: "Private and mixed entries cannot expose links.", + path: ["href"], + }); + } + + if (!entry.startDate.endsWith("-01") || !entry.endDate.endsWith("-01")) { + context.addIssue({ + code: "custom", + message: "Private and mixed entries must use month-level dates.", + path: ["startDate"], + }); + } + + for (const [field, value] of [ + ["title", entry.title], + ["description", entry.description], + ...entry.metrics.map((metric) => ["metrics", metric] as const), + ] as const) { + if (containsPrivateIdentifier(value)) { + context.addIssue({ + code: "custom", + message: + "Private copy contains a number, link, code identifier, or repository-like token.", + path: [field], + }); + } + } + }); + +export type TimelineEditionEntry = z.infer; + +export const timelineSelectionPlanSchema = z + .object({ + selections: z + .array( + z + .object({ + importance: timelineImportanceSchema, + sourceKey: z.string().regex(sourceKeyPattern), + }) + .strict() + ) + .min(1) + .max(24), + windowEnd: z.string().regex(datePattern), + windowStart: z.string().regex(datePattern), + }) + .strict(); + +export type TimelineSelectionPlan = z.infer; + +export const timelinePlanSchema = z + .object({ + entries: z.array(timelineEditionEntrySchema).min(1).max(24), + headline: z.string().trim().min(8).max(100), + standfirst: z.string().trim().min(20).max(280), + windowEnd: z.string().regex(datePattern), + windowStart: z.string().regex(datePattern), + }) + .strict() + .superRefine((plan, context) => { + const windowDays = Math.round( + (Date.parse(`${plan.windowEnd}T00:00:00Z`) - + Date.parse(`${plan.windowStart}T00:00:00Z`)) / + 86_400_000 + ); + + if (windowDays < 365 || windowDays > TIMELINE_WINDOW_DAYS + 2) { + context.addIssue({ + code: "custom", + message: "A timeline edition must cover between 365 and 402 days.", + path: ["windowStart"], + }); + } + + const ids = new Set(); + const leadsByMonth = new Map(); + let leadCount = 0; + let storyCount = 0; + let compactCount = 0; + + for (const [index, entry] of plan.entries.entries()) { + if ( + entry.startDate < plan.windowStart || + entry.endDate > plan.windowEnd + ) { + context.addIssue({ + code: "custom", + message: "Timeline entries must remain inside the edition window.", + path: ["entries", index, "startDate"], + }); + } + + if (ids.has(entry.id)) { + context.addIssue({ + code: "custom", + message: "Timeline entry ids must be unique.", + path: ["entries", index, "id"], + }); + } + ids.add(entry.id); + + if (entry.importance === "lead") { + leadCount += 1; + const month = entry.startDate.slice(0, 7); + const monthCount = (leadsByMonth.get(month) ?? 0) + 1; + leadsByMonth.set(month, monthCount); + if (monthCount > 2) { + context.addIssue({ + code: "custom", + message: "No month may contain more than two lead stories.", + path: ["entries", index, "importance"], + }); + } + } + + if (entry.importance === "story") { + storyCount += 1; + } + + if (entry.importance === "brief" || entry.importance === "pulse") { + compactCount += 1; + } + } + + if (leadCount > 3) { + context.addIssue({ + code: "custom", + message: "A rolling edition may contain at most three lead stories.", + path: ["entries"], + }); + } + + if (storyCount > 4) { + context.addIssue({ + code: "custom", + message: "A rolling edition may contain at most four stories.", + path: ["entries"], + }); + } + + const compactMinimum = + plan.entries.length < 6 + ? 0 + : Math.max(3, Math.ceil(plan.entries.length * 0.4)); + if (compactCount < compactMinimum) { + context.addIssue({ + code: "custom", + message: `An edition of this size needs at least ${compactMinimum} briefs or pulses.`, + path: ["entries"], + }); + } + }); + +export type TimelinePlan = z.infer; + +export const timelineEditionSchema = timelinePlanSchema.extend({ + editionKey: z.string().regex(/^[a-f\d]{64}$/), + generatedAt: z.iso.datetime(), + promptVersion: z.literal(TIMELINE_PROMPT_VERSION), + schemaVersion: z.literal(TIMELINE_SCHEMA_VERSION), + sourceDigest: z.string().regex(/^[a-f\d]{64}$/), +}); + +export type TimelineEdition = z.infer; + +export const editionMatchesTimelinePrivacyPolicy = ( + edition: TimelineEdition, + storedPolicyVersion: string | null, + activePolicyVersion: string | null +) => { + const hasProtectedEntries = edition.entries.some( + (entry) => entry.visibility === "private" || entry.visibility === "mixed" + ); + return ( + !hasProtectedEntries || + (storedPolicyVersion !== null && + storedPolicyVersion === activePolicyVersion) + ); +}; + +export const activityMagnitudeSchema = z.enum([ + "light", + "steady", + "sustained", + "intense", +]); + +export const activityClusterKindSchema = z.enum([ + "curated", + "commit-run", + "recurrence", + "issue-opened", + "pull-request-opened", + "pull-request-reviewed", + "repository-created", + "account-wide-streak", + "anonymous-month", + "public-streak", + "private-month", + "private-streak", +]); +export type ActivityClusterKind = z.infer; + +export const activityClusterSchema = z + .object({ + bucket: workBucketSchema, + cadence: timelineCadenceSchema, + endDate: z.string().regex(datePattern), + facts: z.array(z.string().trim().min(4).max(360)).min(1).max(8), + key: z.string().regex(sourceKeyPattern), + kind: activityClusterKindSchema, + magnitude: activityMagnitudeSchema, + maxImportance: timelineImportanceSchema, + publicHref: timelineHrefSchema.optional(), + publicLabel: z.string().trim().min(2).max(48).optional(), + publicTitle: z.string().trim().min(2).max(120).optional(), + publishable: z.boolean(), + rollupOf: z.array(z.string().regex(sourceKeyPattern)).max(24).default([]), + seriesKey: z.string().regex(sourceKeyPattern), + startDate: z.string().regex(datePattern), + visibility: timelineVisibilitySchema, + }) + .strict() + .superRefine((cluster, context) => { + if (cluster.visibility !== "public") { + for (const value of cluster.facts) { + if (containsPrivateIdentifier(value)) { + context.addIssue({ + code: "custom", + message: "Private activity facts must already be generalized.", + path: ["facts"], + }); + } + } + + if ( + cluster.publicHref !== undefined || + cluster.publicLabel !== undefined || + cluster.publicTitle !== undefined + ) { + context.addIssue({ + code: "custom", + message: "Private activity clusters cannot carry public identity.", + }); + } + } + }); + +export type ActivityCluster = z.infer; + +export const activityDigestSchema = z + .object({ + clusters: z.array(activityClusterSchema).max(120), + coverage: z.enum(["complete", "partial"]), + generatedAt: z.iso.datetime(), + windowEnd: z.string().regex(datePattern), + windowStart: z.string().regex(datePattern), + }) + .strict(); + +export type ActivityDigest = z.infer; + +const importanceRank: Record = { + brief: 1, + lead: 3, + pulse: 0, + story: 2, +}; + +export const digestTimelineValue = (value: unknown) => + createHash("sha256").update(JSON.stringify(value)).digest("hex"); + +const protectedCopyFor = (source: ActivityCluster) => { + if (source.visibility === "anonymous") { + return source.kind === "account-wide-streak" + ? { + description: + "An anonymized, account-wide contribution signal formed a sustained rhythm; repository identity and activity type remain unavailable.", + title: "A sustained account-wide cadence", + } + : { + description: + "An anonymized contribution signal extended beyond repository-resolved work; it is used only as evidence of cadence.", + title: "A wider rhythm in the work", + }; + } + + const subject = + source.bucket === "Applied AI" + ? "applied AI" + : source.bucket.toLocaleLowerCase("en-US"); + const title = + source.cadence === "streak" + ? `A sustained run in ${subject}` + : source.magnitude === "intense" + ? `A concentrated month in ${subject}` + : `A steady month in ${subject}`; + const rhythm = + source.cadence === "streak" + ? "sustained run" + : source.cadence === "clustered" + ? "clustered rhythm" + : "quiet rhythm"; + const description = + source.visibility === "mixed" + ? `Public releases and protected product work moved through the same ${rhythm}; private identity and exact volume remain withheld.` + : `Protected activity formed a ${rhythm}; repository identity and exact volume remain withheld.`; + + return { description, title }; +}; + +const entryKindFor = (kind: ActivityClusterKind): TimelineEntryKind => { + if (kind === "curated" || kind === "repository-created") { + return "project"; + } + if (kind === "issue-opened") { + return "issue"; + } + if (kind === "pull-request-opened") { + return "pull-request"; + } + return "activity"; +}; + +const materializeSelection = ( + source: ActivityCluster, + importance: TimelineImportance +): TimelineEditionEntry => { + const protectedCopy = + source.visibility === "public" ? null : protectedCopyFor(source); + const title = + protectedCopy?.title ?? source.publicTitle ?? "A public work signal"; + const description = + protectedCopy?.description ?? + source.facts[0] ?? + "A verified public work signal appeared during this period."; + + return timelineEditionEntrySchema.parse({ + bucket: source.bucket, + cadence: source.cadence, + description, + endDate: + source.visibility === "public" + ? source.endDate + : `${source.endDate.slice(0, 7)}-01`, + ...(source.visibility === "public" && source.publicHref !== undefined + ? { href: source.publicHref, label: source.publicLabel } + : {}), + id: `source-${digestTimelineValue(source.key).slice(0, 20)}`, + importance, + kind: entryKindFor(source.kind), + metrics: [], + sourceKeys: [source.key], + startDate: + source.visibility === "public" + ? source.startDate + : `${source.startDate.slice(0, 7)}-01`, + title, + visibility: source.visibility, + }); +}; + +const quarterFor = (date: string) => { + const month = Number(date.slice(5, 7)); + return `${date.slice(0, 4)}-Q${Math.floor((month - 1) / 3) + 1}`; +}; + +const isPublicEventCluster = (cluster: ActivityCluster) => + cluster.kind === "issue-opened" || + cluster.kind === "pull-request-opened" || + cluster.kind === "repository-created"; + +export const validateTimelinePlanAgainstDigest = ( + candidate: unknown, + digest: ActivityDigest +): TimelinePlan => { + const selectionPlan = timelineSelectionPlanSchema.parse(candidate); + const clusterByKey = new Map( + digest.clusters.map((cluster) => [cluster.key, cluster]) + ); + const usedSourceKeys = new Set(); + + if ( + selectionPlan.windowStart !== digest.windowStart || + selectionPlan.windowEnd !== digest.windowEnd + ) { + throw new Error("The edition window does not match the activity digest."); + } + + const sources = selectionPlan.selections.map((selection) => { + if (usedSourceKeys.has(selection.sourceKey)) { + throw new Error(`Activity source is reused: ${selection.sourceKey}`); + } + usedSourceKeys.add(selection.sourceKey); + const source = clusterByKey.get(selection.sourceKey); + if (source === undefined) { + throw new Error(`Unknown activity source: ${selection.sourceKey}`); + } + if (!source.publishable) { + throw new Error( + `Activity source is not publishable: ${selection.sourceKey}` + ); + } + if ( + importanceRank[selection.importance] > + importanceRank[source.maxImportance] + ) { + throw new Error( + `Selection ${selection.sourceKey} overstates its source importance.` + ); + } + return { importance: selection.importance, source }; + }); + + const publishable = digest.clusters.filter((cluster) => cluster.publishable); + const minimumEntries = Math.min(9, publishable.length); + if (sources.length < minimumEntries) { + throw new Error( + `This digest needs at least ${minimumEntries} selected entries.` + ); + } + + const compactCount = sources.filter( + ({ importance }) => importance === "brief" || importance === "pulse" + ).length; + const compactMinimum = + sources.length < 6 ? 0 : Math.max(3, Math.ceil(sources.length * 0.4)); + if (compactCount < compactMinimum) { + throw new Error( + `This edition needs at least ${compactMinimum} briefs or pulses.` + ); + } + + const eventCandidates = publishable.filter(isPublicEventCluster); + const selectedEvents = sources.filter(({ source }) => + isPublicEventCluster(source) + ); + const eventMinimum = Math.min( + 3, + eventCandidates.length, + Math.floor(sources.length / 3) + ); + if (selectedEvents.length < eventMinimum) { + throw new Error( + `This edition needs at least ${eventMinimum} public event dispatches.` + ); + } + if (selectedEvents.length > Math.max(1, Math.ceil(sources.length / 3))) { + throw new Error( + "Public event dispatches may occupy at most one third of an edition." + ); + } + + const streakCandidates = publishable.filter( + (cluster) => cluster.cadence === "streak" + ); + if ( + streakCandidates.length > 0 && + !sources.some(({ source }) => source.cadence === "streak") + ) { + throw new Error("A valid consistency streak must be represented."); + } + + const candidateQuarters = new Set( + publishable.map((cluster) => quarterFor(cluster.endDate)) + ); + const selectedQuarters = new Set( + sources.map(({ source }) => quarterFor(source.endDate)) + ); + if ( + sources.length >= candidateQuarters.size && + [...candidateQuarters].some((quarter) => !selectedQuarters.has(quarter)) + ) { + throw new Error("Each active quarter must retain at least one entry."); + } + + return timelinePlanSchema.parse({ + entries: sources + .map(({ importance, source }) => materializeSelection(source, importance)) + .toSorted((left, right) => right.startDate.localeCompare(left.startDate)), + headline: "The work, along one line.", + standfirst: + "A rolling edition of public milestones, sustained runs, and the smaller acts of collaboration between them.", + windowEnd: digest.windowEnd, + windowStart: digest.windowStart, + }); +}; + +export const createTimelineEdition = ( + plan: TimelinePlan, + digest: ActivityDigest, + generatedAt = new Date() +): TimelineEdition => { + const sourceDigest = digestTimelineValue({ + clusters: digest.clusters, + coverage: digest.coverage, + windowEnd: digest.windowEnd, + windowStart: digest.windowStart, + }); + const editionKey = digestTimelineValue({ + plan, + promptVersion: TIMELINE_PROMPT_VERSION, + schemaVersion: TIMELINE_SCHEMA_VERSION, + sourceDigest, + }); + + return timelineEditionSchema.parse({ + ...plan, + editionKey, + generatedAt: generatedAt.toISOString(), + promptVersion: TIMELINE_PROMPT_VERSION, + schemaVersion: TIMELINE_SCHEMA_VERSION, + sourceDigest, + }); +}; diff --git a/src/lib/timeline-editorial.ts b/src/lib/timeline-editorial.ts new file mode 100644 index 0000000..c272806 --- /dev/null +++ b/src/lib/timeline-editorial.ts @@ -0,0 +1,1184 @@ +import { timelineEntries } from "@/content/home"; +import { + activityDigestSchema, + digestTimelineValue, + TIMELINE_WINDOW_DAYS, +} from "@/lib/timeline-core"; +import type { + ActivityCluster, + ActivityDigest, + TimelineImportance, + WorkBucket, +} from "@/lib/timeline-core"; +import { + normalizeTimelinePrivacyKey, + parsePrivateTimelineTaxonomy, + timelinePrivacyPolicyVersion, +} from "@/lib/timeline-privacy"; +import { + readLastCompleteAnonymousTimelineSync, + readLastCompleteTimelineBackfill, + readLatestTimelineSync, + readTimelineActivityDays, + readTimelineContributionTotals, + readTimelinePublicEvents, +} from "@/lib/timeline-store"; +import type { + StoredTimelineActivityDay, + StoredTimelineContributionTotal, + StoredTimelinePublicEvent, +} from "@/lib/timeline-store"; + +const GITHUB_LOGIN = "f0rr0"; +const DAY_IN_MILLISECONDS = 86_400_000; +const PUBLIC_RUN_GAP_DAYS = 10; +const PUBLIC_EVENTS_PER_MONTH = 3; + +interface PublicRun { + rows: StoredTimelineActivityDay[]; +} + +interface PrivateMonth { + bucket: WorkBucket; + month: string; + rows: StoredTimelineActivityDay[]; +} + +export interface AnonymousContributionDay { + contributionCount: number; + day: string; +} + +type ContributionTotal = Pick< + StoredTimelineContributionTotal, + "contributionCount" | "day" +>; + +const dateOnly = (date: Date) => date.toISOString().slice(0, 10); + +const addUtcDays = (date: Date, days: number) => + new Date( + Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate() + days + ) + ); + +const startOfUtcDay = (date: Date) => + new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) + ); + +const daysBetween = (left: string, right: string) => + Math.round( + (Date.parse(`${right}T00:00:00Z`) - Date.parse(`${left}T00:00:00Z`)) / + DAY_IN_MILLISECONDS + ); + +const startOfActivityWeek = (activityDay: string) => { + const date = new Date(`${activityDay}T00:00:00Z`); + const day = date.getUTCDay(); + const mondayOffset = day === 0 ? -6 : 1 - day; + return dateOnly(addUtcDays(date, mondayOffset)); +}; + +export const calculateAnonymousContributionDays = (input: { + events?: readonly Pick[]; + rows: readonly Pick[]; + totals: readonly ContributionTotal[]; +}): AnonymousContributionDay[] => { + const knownByDay = new Map(); + for (const row of input.rows) { + knownByDay.set(row.day, (knownByDay.get(row.day) ?? 0) + row.commitCount); + } + const seenEvents = new Set(); + for (const event of input.events ?? []) { + if (seenEvents.has(event.id)) { + continue; + } + seenEvents.add(event.id); + knownByDay.set(event.day, (knownByDay.get(event.day) ?? 0) + 1); + } + + return input.totals.flatMap(({ contributionCount, day }) => { + const anonymousCount = Math.max( + 0, + contributionCount - (knownByDay.get(day) ?? 0) + ); + return anonymousCount === 0 + ? [] + : [{ contributionCount: anonymousCount, day }]; + }); +}; + +const indefiniteArticle = (value: string) => + /^[aeiou]/i.test(value) ? "An" : "A"; + +const incrementMonth = (month: string) => { + const date = new Date(`${month}-01T00:00:00Z`); + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1)) + .toISOString() + .slice(0, 7); +}; + +const mode = (values: readonly T[], fallback: T) => { + const counts = new Map(); + let selected = fallback; + let selectedCount = -1; + for (const value of values) { + const count = (counts.get(value) ?? 0) + 1; + counts.set(value, count); + if (count > selectedCount) { + selected = value; + selectedCount = count; + } + } + return selected; +}; + +const magnitudeFor = (commitCount: number, activeDays: number) => { + if (commitCount >= 80 || activeDays >= 16) { + return "intense" as const; + } + if (commitCount >= 36 || activeDays >= 9) { + return "sustained" as const; + } + if (commitCount >= 12 || activeDays >= 4) { + return "steady" as const; + } + return "light" as const; +}; + +const cadenceFor = (activeDays: number, spanDays: number) => { + if (activeDays >= 8 && spanDays >= 21) { + return "streak" as const; + } + if (activeDays >= 3) { + return "clustered" as const; + } + return "isolated" as const; +}; + +const importanceFor = ( + magnitude: ReturnType, + cadence: ReturnType +): TimelineImportance => { + if (magnitude === "intense" && cadence === "streak") { + return "lead"; + } + if (magnitude === "sustained" || cadence === "streak") { + return "story"; + } + if (magnitude === "steady") { + return "brief"; + } + return "pulse"; +}; + +const languagePhrase = (languageFamily: string) => { + switch (languageFamily) { + case "data": { + return "data systems"; + } + case "documentation": { + return "documentation"; + } + case "infrastructure": { + return "infrastructure"; + } + case "mobile": { + return "mobile systems"; + } + case "systems": { + return "systems work"; + } + case "web": { + return "web product work"; + } + default: { + return "product engineering"; + } + } +}; + +const splitPublicRuns = ( + rows: readonly StoredTimelineActivityDay[] +): PublicRun[] => { + const byRepo = new Map(); + for (const row of rows) { + if (row.visibility !== "public") { + continue; + } + const repoRows = byRepo.get(row.repoKey) ?? []; + repoRows.push(row); + byRepo.set(row.repoKey, repoRows); + } + + const runs: PublicRun[] = []; + for (const repoRows of byRepo.values()) { + const sorted = repoRows.toSorted((left, right) => + left.day.localeCompare(right.day) + ); + let current: StoredTimelineActivityDay[] = []; + for (const row of sorted) { + const previous = current.at(-1); + if ( + previous !== undefined && + daysBetween(previous.day, row.day) > PUBLIC_RUN_GAP_DAYS + ) { + runs.push({ rows: current }); + current = []; + } + current.push(row); + } + if (current.length > 0) { + runs.push({ rows: current }); + } + } + + return runs; +}; + +const publicClusterFrom = (run: PublicRun): ActivityCluster | null => { + const [first] = run.rows; + const last = run.rows.at(-1); + if ( + first === undefined || + last === undefined || + first.publicRepoName === null || + first.publicRepoUrl === null + ) { + return null; + } + + const commitCount = run.rows.reduce((sum, row) => sum + row.commitCount, 0); + const activeDays = new Set(run.rows.map((row) => row.day)).size; + const spanDays = daysBetween(first.day, last.day) + 1; + const magnitude = magnitudeFor(commitCount, activeDays); + const cadence = cadenceFor(activeDays, spanDays); + const languageFamily = mode( + run.rows.map((row) => row.languageFamily), + "other" + ); + const bucket = mode( + run.rows.map((row) => row.bucket as WorkBucket), + "Open source" as WorkBucket + ); + const repositoryLabel = first.publicRepoName.split("/").at(-1) ?? "project"; + + return { + bucket, + cadence, + endDate: last.day, + facts: [ + `Public work appeared across ${activeDays} active days during a ${spanDays}-day span.`, + `The run centered on ${languagePhrase(languageFamily)}.`, + ], + key: `public:${digestTimelineValue({ end: last.day, repo: first.repoKey, start: first.day }).slice(0, 24)}`, + kind: "commit-run", + magnitude, + maxImportance: + importanceFor(magnitude, cadence) === "lead" + ? "story" + : importanceFor(magnitude, cadence), + publicHref: first.publicRepoUrl, + publicLabel: `View ${repositoryLabel}`, + publicTitle: `${repositoryLabel}, in motion`, + publishable: cadence !== "isolated", + rollupOf: [], + seriesKey: `series:${first.repoKey}`, + startDate: first.day, + visibility: "public", + }; +}; + +const githubRepositoryUrlFrom = (href: string) => { + try { + const url = new URL(href); + const segments = url.pathname.split("/").filter(Boolean); + return url.protocol === "https:" && + url.hostname === "github.com" && + segments.length >= 2 + ? `https://github.com/${segments[0]}/${segments[1]}`.toLocaleLowerCase( + "en-US" + ) + : null; + } catch { + return null; + } +}; + +const canonicalPublicHref = (href: string | undefined) => { + if (href === undefined) { + return null; + } + try { + const url = new URL(href); + if (url.protocol !== "https:") { + return null; + } + url.hash = ""; + url.search = ""; + const path = url.pathname.replace(/\/+$/, "").toLocaleLowerCase("en-US"); + return `${url.origin.toLocaleLowerCase("en-US")}${path}`; + } catch { + return null; + } +}; + +const curatedPublicClusters = ( + rows: readonly StoredTimelineActivityDay[], + windowStart: string, + windowEnd: string +): ActivityCluster[] => { + const publicRepositoryUrls = new Set( + rows.flatMap((row) => + row.visibility === "public" && row.publicRepoUrl !== null + ? [row.publicRepoUrl.toLocaleLowerCase("en-US")] + : [] + ) + ); + const repoKeyByUrl = new Map( + rows.flatMap((row) => + row.visibility === "public" && row.publicRepoUrl !== null + ? [[row.publicRepoUrl.toLocaleLowerCase("en-US"), row.repoKey] as const] + : [] + ) + ); + + return timelineEntries.flatMap((entry) => { + if ( + entry.date < windowStart || + entry.date > windowEnd || + ("private" in entry && entry.private) + ) { + return []; + } + const repositoryUrl = githubRepositoryUrlFrom(entry.href); + if (repositoryUrl !== null && !publicRepositoryUrls.has(repositoryUrl)) { + return []; + } + + const importance = entry.importance ?? "story"; + return [ + { + bucket: entry.bucket, + cadence: importance === "lead" ? "clustered" : "isolated", + endDate: entry.date, + facts: [entry.description], + key: `curated:${digestTimelineValue({ date: entry.date, title: entry.title }).slice(0, 24)}`, + kind: "curated", + magnitude: + importance === "lead" + ? "intense" + : importance === "story" + ? "sustained" + : "steady", + maxImportance: importance, + publicHref: entry.href, + publicLabel: entry.label, + publicTitle: entry.title, + publishable: true, + rollupOf: [], + seriesKey: + repositoryUrl === null + ? `series:${digestTimelineValue({ href: entry.href }).slice(0, 24)}` + : `series:${repoKeyByUrl.get(repositoryUrl) ?? digestTimelineValue(repositoryUrl).slice(0, 24)}`, + startDate: entry.date, + visibility: "public", + } satisfies ActivityCluster, + ]; + }); +}; + +const groupPrivateMonths = ( + rows: readonly StoredTimelineActivityDay[] +): PrivateMonth[] => { + const groups = new Map(); + for (const row of rows) { + if (row.visibility !== "private") { + continue; + } + + const month = row.day.slice(0, 7); + const bucket = row.bucket as WorkBucket; + const key = `${month}:${bucket}`; + const group = groups.get(key) ?? { bucket, month, rows: [] }; + group.rows.push(row); + groups.set(key, group); + } + return [...groups.values()].toSorted((left, right) => + left.month.localeCompare(right.month) + ); +}; + +const privateClusterFrom = (group: PrivateMonth): ActivityCluster | null => { + const commitCount = group.rows.reduce((sum, row) => sum + row.commitCount, 0); + const activeDays = new Set(group.rows.map((row) => row.day)).size; + const repoCounts = new Map(); + const domains = new Set(); + for (const row of group.rows) { + repoCounts.set( + row.repoKey, + (repoCounts.get(row.repoKey) ?? 0) + row.commitCount + ); + if (row.privacyDomainKey !== null) { + domains.add(row.privacyDomainKey); + } + } + + const dominantShare = + Math.max(0, ...repoCounts.values()) / Math.max(1, commitCount); + const themeIsDiverse = + group.bucket !== "Private product work" && + repoCounts.size >= 3 && + domains.size >= 2 && + commitCount >= 20 && + activeDays >= 5 && + dominantShare <= 0.6; + const bucket = themeIsDiverse ? group.bucket : "Private product work"; + const publishable = commitCount >= 10 && activeDays >= 3; + if (!publishable) { + return null; + } + + const magnitude = magnitudeFor(commitCount, activeDays); + const cadence = cadenceFor(activeDays, 31); + const facts = themeIsDiverse + ? [ + `${indefiniteArticle(magnitude)} ${magnitude} month across several independent private work streams.`, + `The broad pattern was ${bucket.toLocaleLowerCase("en-US")}.`, + ] + : [ + `${indefiniteArticle(magnitude)} ${magnitude} month of private product work.`, + "Repository identity and exact activity remain deliberately withheld.", + ]; + + return { + bucket, + cadence, + endDate: `${group.month}-01`, + facts, + key: `private:${digestTimelineValue({ month: group.month, sourceBucket: group.bucket }).slice(0, 24)}`, + kind: "private-month", + magnitude, + maxImportance: + magnitude === "intense" || magnitude === "sustained" ? "story" : "brief", + publishable: true, + rollupOf: [], + seriesKey: `private-series:${digestTimelineValue(group.bucket).slice(0, 24)}`, + startDate: `${group.month}-01`, + visibility: "private", + }; +}; + +const privateStreakClusters = ( + monthlyClusters: readonly ActivityCluster[] +): ActivityCluster[] => { + const byBucket = new Map(); + for (const cluster of monthlyClusters) { + const bucketClusters = byBucket.get(cluster.bucket) ?? []; + bucketClusters.push(cluster); + byBucket.set(cluster.bucket, bucketClusters); + } + + const streaks: ActivityCluster[] = []; + for (const [bucket, clusters] of byBucket) { + const sorted = clusters.toSorted((left, right) => + left.startDate.localeCompare(right.startDate) + ); + let current: ActivityCluster[] = []; + + const finish = () => { + if (current.length < 3) { + current = []; + return; + } + const [first] = current; + const last = current.at(-1); + if (first === undefined || last === undefined) { + current = []; + return; + } + streaks.push({ + bucket, + cadence: "streak", + endDate: last.endDate, + facts: [ + "A sustained private work pattern held across several months.", + "Only the broad cadence survives the publication boundary.", + ], + key: `streak:${digestTimelineValue({ bucket, end: last.endDate, sources: current.map((cluster) => cluster.key), start: first.startDate }).slice(0, 24)}`, + kind: "private-streak", + magnitude: "sustained", + maxImportance: current.length >= 6 ? "lead" : "story", + publishable: true, + rollupOf: current.map((cluster) => cluster.key), + seriesKey: `private-series:${digestTimelineValue(bucket).slice(0, 24)}`, + startDate: first.startDate, + visibility: "private", + }); + current = []; + }; + + for (const cluster of sorted) { + const previous = current.at(-1); + if ( + previous !== undefined && + incrementMonth(previous.startDate.slice(0, 7)) !== + cluster.startDate.slice(0, 7) + ) { + finish(); + } + current.push(cluster); + } + finish(); + } + + return streaks; +}; + +const publicStreakCluster = ( + rows: readonly StoredTimelineActivityDay[], + events: readonly StoredTimelinePublicEvent[] +): ActivityCluster | null => { + const publicRows = rows.filter((row) => row.visibility === "public"); + const activeWeekKeys = new Set( + [ + ...publicRows.map((row) => row.day), + ...events.map((event) => event.day), + ].map(startOfActivityWeek) + ); + const weeks = [...activeWeekKeys].toSorted(); + let longest: string[] = []; + let current: string[] = []; + for (const week of weeks) { + const previous = current.at(-1); + if (previous !== undefined && daysBetween(previous, week) !== 7) { + if (current.length > longest.length) { + longest = current; + } + current = []; + } + current.push(week); + } + if (current.length > longest.length) { + longest = current; + } + if (longest.length < 5) { + return null; + } + + const [first] = longest; + const last = longest.at(-1); + if (first === undefined || last === undefined) { + return null; + } + const repositories = new Set([ + ...publicRows + .filter( + (row) => + row.day >= first && + row.day <= + addUtcDays(new Date(`${last}T00:00:00Z`), 6) + .toISOString() + .slice(0, 10) + ) + .map((row) => row.repoKey), + ...events + .filter( + (event) => + event.day >= first && + event.day <= + addUtcDays(new Date(`${last}T00:00:00Z`), 6) + .toISOString() + .slice(0, 10) + ) + .map((event) => event.repoKey), + ]); + return { + bucket: "Product systems", + cadence: "streak", + endDate: last, + facts: [ + `Public contributions appeared in ${longest.length} consecutive weeks across ${repositories.size} repositories.`, + "Commit runs and discrete collaboration events count once within each active week.", + ], + key: `streak:${digestTimelineValue({ end: last, scope: "public", start: first }).slice(0, 24)}`, + kind: "public-streak", + magnitude: "intense", + maxImportance: "lead", + publicTitle: "A sustained public cadence", + publishable: true, + rollupOf: [], + seriesKey: "series:public-cadence", + startDate: first, + visibility: "public", + }; +}; + +const longestActiveWeekRun = (totals: readonly ContributionTotal[]) => { + const weeks = [ + ...new Set( + totals + .filter((total) => total.contributionCount > 0) + .map((total) => startOfActivityWeek(total.day)) + ), + ].toSorted(); + let longest: string[] = []; + let current: string[] = []; + for (const week of weeks) { + const previous = current.at(-1); + if (previous !== undefined && daysBetween(previous, week) !== 7) { + if (current.length > longest.length) { + longest = current; + } + current = []; + } + current.push(week); + } + return current.length > longest.length ? current : longest; +}; + +const accountWideStreakCluster = ( + totals: readonly ContributionTotal[] +): ActivityCluster | null => { + const longest = longestActiveWeekRun(totals); + const [first] = longest; + const last = longest.at(-1); + if (longest.length < 5 || first === undefined || last === undefined) { + return null; + } + + return { + bucket: "Across the work", + cadence: "streak", + endDate: last, + facts: [ + "The anonymized account-wide calendar shows a sustained run across consecutive active weeks.", + "Repository identity and activity type remain unavailable, so no project theme is inferred.", + ], + key: `streak:${digestTimelineValue({ end: last, scope: "account-wide", start: first }).slice(0, 24)}`, + kind: "account-wide-streak", + magnitude: longest.length >= 12 ? "intense" : "sustained", + maxImportance: longest.length >= 12 ? "lead" : "story", + publishable: true, + rollupOf: [], + seriesKey: "anonymous-series:account-wide", + startDate: first, + visibility: "anonymous", + }; +}; + +const anonymousMonthlyClusters = ( + days: readonly AnonymousContributionDay[] +): ActivityCluster[] => { + const byMonth = new Map(); + for (const day of days) { + const month = day.day.slice(0, 7); + const monthDays = byMonth.get(month) ?? []; + monthDays.push(day); + byMonth.set(month, monthDays); + } + + return [...byMonth.entries()].flatMap(([month, monthDays]) => { + const contributionCount = monthDays.reduce( + (sum, day) => sum + day.contributionCount, + 0 + ); + const activeDays = monthDays.length; + if (activeDays < 3 || contributionCount < 5) { + return []; + } + const magnitude = magnitudeFor(contributionCount, activeDays); + return [ + { + bucket: "Across the work", + cadence: "clustered", + endDate: `${month}-01`, + facts: [ + "An anonymized account-wide rhythm extended beyond repository-resolved activity.", + "Repository identity and activity type are unavailable, so no project theme is inferred.", + ], + key: `anonymous:${digestTimelineValue({ month, source: "residual" }).slice(0, 24)}`, + kind: "anonymous-month", + magnitude, + maxImportance: + magnitude === "intense" || magnitude === "sustained" + ? "brief" + : "pulse", + publishable: true, + rollupOf: [], + seriesKey: "anonymous-series:account-wide", + startDate: `${month}-01`, + visibility: "anonymous", + } satisfies ActivityCluster, + ]; + }); +}; + +const publicEventClusterFrom = ( + event: StoredTimelinePublicEvent +): ActivityCluster | null => { + if (event.eventKind === "pull_request_reviewed") { + return null; + } + const repositoryLabel = event.publicRepoName.split("/").at(-1) ?? "project"; + const copy = + event.eventKind === "issue_opened" + ? { + description: `Opened a public issue in ${event.publicRepoName}; the thread remains available on GitHub.`, + kind: "issue-opened" as const, + label: "View issue", + maxImportance: "brief" as const, + title: event.publicTitle, + } + : event.eventKind === "pull_request_opened" + ? { + description: `Opened a public pull request in ${event.publicRepoName}; the proposed change remains available on GitHub.`, + kind: "pull-request-opened" as const, + label: "View pull request", + maxImportance: "brief" as const, + title: event.publicTitle, + } + : { + description: `Created ${event.publicRepoName} as a public repository.`, + kind: "repository-created" as const, + label: `View ${repositoryLabel}`, + maxImportance: "brief" as const, + title: `${repositoryLabel}, made public`, + }; + + return { + bucket: event.bucket as WorkBucket, + cadence: "isolated", + endDate: event.day, + facts: [copy.description], + key: `event:${event.id.slice(0, 24)}`, + kind: copy.kind, + magnitude: "light", + maxImportance: copy.maxImportance, + publicHref: event.publicUrl, + publicLabel: copy.label, + publicTitle: copy.title, + publishable: true, + rollupOf: [], + seriesKey: `series:${event.repoKey}`, + startDate: event.day, + visibility: "public", + }; +}; + +const representativePublicEventClusters = ( + clusters: readonly ActivityCluster[] +) => { + const byMonth = new Map(); + for (const cluster of clusters) { + const month = cluster.startDate.slice(0, 7); + const monthClusters = byMonth.get(month) ?? []; + monthClusters.push(cluster); + byMonth.set(month, monthClusters); + } + + return [...byMonth.values()].flatMap((monthClusters) => { + const sorted = monthClusters.toSorted( + (left, right) => + right.startDate.localeCompare(left.startDate) || + left.key.localeCompare(right.key) + ); + const selected: ActivityCluster[] = []; + const selectedKeys = new Set(); + const select = (cluster: ActivityCluster | undefined) => { + if ( + cluster !== undefined && + selected.length < PUBLIC_EVENTS_PER_MONTH && + !selectedKeys.has(cluster.key) + ) { + selected.push(cluster); + selectedKeys.add(cluster.key); + } + }; + + for (const kind of [ + "repository-created", + "issue-opened", + "pull-request-opened", + ] as const) { + select(sorted.find((cluster) => cluster.kind === kind)); + } + const selectedSeries = new Set( + selected.map((cluster) => cluster.seriesKey) + ); + for (const cluster of sorted) { + if (!selectedSeries.has(cluster.seriesKey)) { + select(cluster); + selectedSeries.add(cluster.seriesKey); + } + } + for (const cluster of sorted) { + select(cluster); + } + return selected; + }); +}; + +const publicRecurrenceClusters = ( + clusters: readonly ActivityCluster[] +): ActivityCluster[] => { + const bySeries = new Map(); + for (const cluster of clusters) { + const series = bySeries.get(cluster.seriesKey) ?? []; + series.push(cluster); + bySeries.set(cluster.seriesKey, series); + } + + return [...bySeries.entries()].flatMap(([seriesKey, series]) => { + const sorted = series.toSorted((left, right) => + left.startDate.localeCompare(right.startDate) + ); + const [first] = sorted; + const last = sorted.at(-1); + if ( + sorted.length < 3 || + first === undefined || + last === undefined || + daysBetween(first.startDate, last.endDate) < 60 || + first.publicHref === undefined + ) { + return []; + } + const repositoryLabel = + first.publicLabel?.replace(/^View /, "") ?? "A public project"; + return [ + { + bucket: first.bucket, + cadence: "clustered", + endDate: last.endDate, + facts: [ + `${repositoryLabel} returned in ${sorted.length} distinct public work runs across the year.`, + ], + key: `recurrence:${digestTimelineValue({ seriesKey, sources: sorted.map((cluster) => cluster.key) }).slice(0, 24)}`, + kind: "recurrence", + magnitude: "sustained", + maxImportance: "story", + publicHref: first.publicHref, + publicLabel: first.publicLabel, + publicTitle: `${repositoryLabel}, revisited`, + publishable: true, + rollupOf: sorted.map((cluster) => cluster.key), + seriesKey, + startDate: first.startDate, + visibility: "public", + } satisfies ActivityCluster, + ]; + }); +}; + +export const createTimelineActivityDigest = (input: { + anonymousTotals?: readonly ContributionTotal[]; + coverage: "complete" | "partial"; + events?: readonly StoredTimelinePublicEvent[]; + generatedAt: Date; + rows: readonly StoredTimelineActivityDay[]; + windowEnd: string; + windowStart: string; +}): ActivityDigest => { + const events = input.events ?? []; + const anonymousTotals = input.anonymousTotals ?? []; + const rawPublicClusters = splitPublicRuns(input.rows).flatMap((run) => { + const cluster = publicClusterFrom(run); + return cluster === null ? [] : [cluster]; + }); + const rawEventClusters = events.flatMap((event) => { + const cluster = publicEventClusterFrom(event); + return cluster === null ? [] : [cluster]; + }); + const curatedClusters = curatedPublicClusters( + input.rows, + input.windowStart, + input.windowEnd + ); + const curatedHrefs = new Set( + curatedClusters.flatMap((cluster) => { + const href = canonicalPublicHref(cluster.publicHref); + return href === null ? [] : [href]; + }) + ); + const eventClusters = representativePublicEventClusters( + rawEventClusters.filter((cluster) => { + const eventHref = canonicalPublicHref(cluster.publicHref); + if (eventHref !== null && curatedHrefs.has(eventHref)) { + return false; + } + if (cluster.kind !== "repository-created") { + return true; + } + return !curatedClusters.some( + (curated) => + canonicalPublicHref(curated.publicHref) === eventHref && + Math.abs(daysBetween(curated.endDate, cluster.endDate)) <= 7 + ); + }) + ); + const recurrenceClusters = publicRecurrenceClusters(rawPublicClusters); + const recurringSeries = new Set( + recurrenceClusters.map((cluster) => cluster.seriesKey) + ); + const publicClusters = rawPublicClusters.filter((cluster) => { + if (recurringSeries.has(cluster.seriesKey)) { + return false; + } + if (cluster.cadence === "streak") { + return true; + } + const overlapsCuratedMarker = curatedClusters.some( + (marker) => + marker.seriesKey === cluster.seriesKey && + marker.endDate >= cluster.startDate && + marker.endDate <= cluster.endDate + ); + const overlapsCompactEvent = + (cluster.maxImportance === "brief" || + cluster.maxImportance === "pulse") && + eventClusters.some( + (marker) => + marker.seriesKey === cluster.seriesKey && + marker.endDate >= cluster.startDate && + marker.endDate <= cluster.endDate + ); + return !(overlapsCuratedMarker || overlapsCompactEvent); + }); + const privateGroups = groupPrivateMonths(input.rows); + const genericRowsByMonth = new Map(); + const privateSpecificClusters = privateGroups.flatMap((group) => { + const cluster = privateClusterFrom(group); + if (cluster === null || cluster.bucket === "Private product work") { + genericRowsByMonth.set(group.month, [ + ...(genericRowsByMonth.get(group.month) ?? []), + ...group.rows, + ]); + return []; + } + return [cluster]; + }); + const privateGenericClusters = [...genericRowsByMonth.entries()].flatMap( + ([month, rows]) => { + const cluster = privateClusterFrom({ + bucket: "Private product work", + month, + rows, + }); + return cluster === null ? [] : [cluster]; + } + ); + const privateMonthlyClusters = [ + ...privateSpecificClusters, + ...privateGenericClusters, + ]; + const rawAnonymousClusters = anonymousMonthlyClusters( + calculateAnonymousContributionDays({ + events, + rows: input.rows, + totals: anonymousTotals, + }) + ); + const accountWideStreak = accountWideStreakCluster(anonymousTotals); + const anonymousClusters = + accountWideStreak === null + ? rawAnonymousClusters + : rawAnonymousClusters.filter( + (cluster) => + cluster.startDate.slice(0, 7) !== + accountWideStreak.endDate.slice(0, 7) + ); + const publicStreak = + accountWideStreak === null ? publicStreakCluster(input.rows, events) : null; + const importanceRank: Record = { + brief: 1, + lead: 3, + pulse: 0, + story: 2, + }; + const clusters = [ + ...curatedClusters, + ...eventClusters, + ...recurrenceClusters, + ...publicClusters, + ...privateMonthlyClusters, + ...privateStreakClusters(privateMonthlyClusters), + ...anonymousClusters, + ...(accountWideStreak === null ? [] : [accountWideStreak]), + ...(publicStreak === null ? [] : [publicStreak]), + ] + .flatMap((cluster) => { + if (cluster.visibility === "public") { + return [ + { + ...cluster, + startDate: + cluster.startDate < input.windowStart + ? input.windowStart + : cluster.startDate, + }, + ]; + } + let startDate = `${cluster.startDate.slice(0, 7)}-01`; + if (startDate < input.windowStart) { + startDate = `${incrementMonth(startDate.slice(0, 7))}-01`; + } + const endDate = `${cluster.endDate.slice(0, 7)}-01`; + return startDate > endDate ? [] : [{ ...cluster, endDate, startDate }]; + }) + .toSorted( + (left, right) => + importanceRank[right.maxImportance] - + importanceRank[left.maxImportance] || + right.startDate.localeCompare(left.startDate) || + left.key.localeCompare(right.key) + ) + .slice(0, 120) + .toSorted((left, right) => right.startDate.localeCompare(left.startDate)); + + return activityDigestSchema.parse({ + clusters, + coverage: input.coverage, + generatedAt: input.generatedAt.toISOString(), + windowEnd: input.windowEnd, + windowStart: input.windowStart, + }); +}; + +const syncAgeInDays = (completedAt: Date | null | undefined, now: Date) => + completedAt === null || completedAt === undefined + ? Number.POSITIVE_INFINITY + : Math.floor((now.getTime() - completedAt.getTime()) / DAY_IN_MILLISECONDS); + +const eligibleActivityRows = ( + rows: readonly StoredTimelineActivityDay[], + activePrivacyVersion: string | null +) => + rows.filter( + (row) => + row.visibility === "public" || + (activePrivacyVersion !== null && + row.privacyPolicyVersion === activePrivacyVersion) + ); + +const currentAnonymousTotals = (input: { + lastSync: { + completedAt: Date | null; + windowEnd: string; + } | null; + now: Date; + totals: readonly StoredTimelineContributionTotal[]; + windowEnd: string; + windowStart: string; +}) => { + const start = new Date(`${input.windowStart}T00:00:00Z`); + const windowIsComplete = + input.totals.length === TIMELINE_WINDOW_DAYS && + input.totals.every( + (total, index) => total.day === dateOnly(addUtcDays(start, index)) + ); + const syncAge = syncAgeInDays(input.lastSync?.completedAt, input.now); + return windowIsComplete && + input.lastSync?.windowEnd === input.windowEnd && + syncAge >= 0 && + syncAge <= 2 + ? input.totals + : []; +}; + +const timelineCoverage = (input: { + lastFullSync: { + windowEnd: string; + windowStart: string; + } | null; + latestSync: { + completedAt: Date | null; + coverage: string; + status: string; + } | null; + now: Date; + windowEnd: string; + windowStart: string; +}): "complete" | "partial" => { + if (input.lastFullSync === null) { + return "partial"; + } + const lastSyncSpan = + daysBetween(input.lastFullSync.windowStart, input.lastFullSync.windowEnd) + + 1; + const backfillAge = daysBetween( + input.lastFullSync.windowEnd, + input.windowEnd + ); + const latestSyncAge = syncAgeInDays(input.latestSync?.completedAt, input.now); + return lastSyncSpan >= 365 && + input.lastFullSync.windowStart <= input.windowStart && + backfillAge >= 0 && + backfillAge <= 8 && + input.latestSync?.status === "completed" && + input.latestSync.coverage === "complete" && + latestSyncAge >= 0 && + latestSyncAge <= 2 + ? "complete" + : "partial"; +}; + +export const loadTimelineActivityDigest = async ( + now = new Date() +): Promise => { + const end = startOfUtcDay(now); + const windowEnd = dateOnly(end); + const windowStart = dateOnly(addUtcDays(end, -(TIMELINE_WINDOW_DAYS - 1))); + const [ + rows, + events, + anonymousTotals, + lastAnonymousSync, + lastFullSync, + latestSync, + ] = await Promise.all([ + readTimelineActivityDays(GITHUB_LOGIN, windowStart, windowEnd), + readTimelinePublicEvents(GITHUB_LOGIN, windowStart, windowEnd), + readTimelineContributionTotals(GITHUB_LOGIN, windowStart, windowEnd), + readLastCompleteAnonymousTimelineSync(), + readLastCompleteTimelineBackfill(), + readLatestTimelineSync(), + ]); + const privacyKey = normalizeTimelinePrivacyKey( + process.env.TIMELINE_PRIVACY_KEY + ); + const activePrivacyVersion = + privacyKey === null + ? null + : timelinePrivacyPolicyVersion( + privacyKey, + parsePrivateTimelineTaxonomy(process.env.TIMELINE_PRIVATE_TAXONOMY) + ); + const eligibleRows = eligibleActivityRows(rows, activePrivacyVersion); + + return createTimelineActivityDigest({ + anonymousTotals: currentAnonymousTotals({ + lastSync: lastAnonymousSync, + now, + totals: anonymousTotals, + windowEnd, + windowStart, + }), + coverage: timelineCoverage({ + lastFullSync, + latestSync, + now, + windowEnd, + windowStart, + }), + generatedAt: now, + events, + rows: eligibleRows, + windowEnd, + windowStart, + }); +}; diff --git a/src/lib/timeline-fallback.ts b/src/lib/timeline-fallback.ts new file mode 100644 index 0000000..6331e56 --- /dev/null +++ b/src/lib/timeline-fallback.ts @@ -0,0 +1,245 @@ +import { timelineEntries } from "@/content/home"; +import type { GitHubActivity } from "@/lib/github-profile-core"; +import { + digestTimelineValue, + TIMELINE_PROMPT_VERSION, + TIMELINE_SCHEMA_VERSION, + TIMELINE_WINDOW_DAYS, + timelineEditionSchema, + timelinePlanSchema, +} from "@/lib/timeline-core"; +import type { + TimelineEdition, + TimelineEditionEntry, +} from "@/lib/timeline-core"; + +const DAY_IN_MILLISECONDS = 86_400_000; + +const slug = (value: string) => + value + .toLocaleLowerCase("en-US") + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/^-|-$/g, "") + .slice(0, 70); + +const dateOnly = (date: Date) => date.toISOString().slice(0, 10); + +const addUtcDays = (date: Date, days: number) => + new Date(date.getTime() + days * DAY_IN_MILLISECONDS); + +const incrementMonth = (month: string) => { + const date = new Date(`${month}-01T00:00:00Z`); + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1)) + .toISOString() + .slice(0, 7); +}; + +const githubRepositoryUrlFrom = (href: string) => { + try { + const url = new URL(href); + const segments = url.pathname.split("/").filter(Boolean); + return url.protocol === "https:" && + url.hostname === "github.com" && + segments.length >= 2 + ? `https://github.com/${segments[0]}/${segments[1]}`.toLocaleLowerCase( + "en-US" + ) + : null; + } catch { + return null; + } +}; + +const fallbackActivityEntries = ( + activity: GitHubActivity, + windowStart: string +): TimelineEditionEntry[] => { + if (activity.status === "unavailable") { + return []; + } + + const countByMonth = new Map(); + for (const week of activity.weeks) { + if (week.weekStart < windowStart) { + continue; + } + const month = week.weekStart.slice(0, 7); + countByMonth.set( + month, + (countByMonth.get(month) ?? 0) + week.contributionCount + ); + } + + const months = [...countByMonth.entries()] + .filter(([, count]) => count > 0) + .toSorted((left, right) => right[0].localeCompare(left[0])) + .slice(0, 10); + const rankedCounts = months + .map(([, count]) => count) + .toSorted((left, right) => left - right); + const highThreshold = + rankedCounts.at(Math.floor(rankedCounts.length * 0.7)) ?? 0; + + const activeWeeks = activity.weeks + .filter( + (week) => + week.contributionCount > 0 && week.weekStart >= windowStart.slice(0, 7) + ) + .toSorted((left, right) => left.weekStart.localeCompare(right.weekStart)); + let longest: typeof activeWeeks = []; + let current: typeof activeWeeks = []; + for (const week of activeWeeks) { + const previous = current.at(-1); + if ( + previous !== undefined && + Date.parse(`${week.weekStart}T00:00:00Z`) - + Date.parse(`${previous.weekStart}T00:00:00Z`) !== + 7 * DAY_IN_MILLISECONDS + ) { + if (current.length > longest.length) { + longest = current; + } + current = []; + } + current.push(week); + } + if (current.length > longest.length) { + longest = current; + } + + const [firstStreakWeek] = longest; + const lastStreakWeek = longest.at(-1); + const firstPublishableMonth = windowStart.endsWith("-01") + ? windowStart + : `${incrementMonth(windowStart.slice(0, 7))}-01`; + const streakEntry = + longest.length >= 5 && + firstStreakWeek !== undefined && + lastStreakWeek !== undefined + ? [ + { + bucket: "Across the work", + cadence: "streak", + description: + "An anonymized, account-wide contribution signal formed a sustained rhythm; repository identity and activity type remain unavailable.", + endDate: `${lastStreakWeek.weekStart.slice(0, 7)}-01`, + id: `activity-streak-${firstStreakWeek.weekStart.slice(0, 7)}-${lastStreakWeek.weekStart.slice(0, 7)}`, + importance: "story", + kind: "activity", + metrics: [], + sourceKeys: [ + `streak:${digestTimelineValue({ end: lastStreakWeek.weekStart, scope: "account-wide", start: firstStreakWeek.weekStart }).slice(0, 24)}`, + ], + startDate: + `${firstStreakWeek.weekStart.slice(0, 7)}-01` < + firstPublishableMonth + ? firstPublishableMonth + : `${firstStreakWeek.weekStart.slice(0, 7)}-01`, + title: "A sustained account-wide cadence", + visibility: "anonymous", + } satisfies TimelineEditionEntry, + ] + : []; + + return [ + ...streakEntry, + ...months + .filter( + ([month]) => + lastStreakWeek === undefined || + month !== lastStreakWeek.weekStart.slice(0, 7) + ) + .map(([month, count], index) => { + const sourceKey = `pulse:${digestTimelineValue({ count, month }).slice(0, 24)}`; + const cadence = count >= highThreshold ? "concentrated" : "steady"; + return { + bucket: "Across the work", + cadence: "clustered", + description: `A ${cadence} contribution-calendar rhythm appeared across the month; identities and exact volume remain withheld.`, + endDate: `${month}-01`, + id: `activity-${month}-${index}`, + importance: count >= highThreshold ? "brief" : "pulse", + kind: "activity", + metrics: [], + sourceKeys: [sourceKey], + startDate: `${month}-01`, + title: `${cadence === "concentrated" ? "A concentrated" : "A steady"} month in the work`, + visibility: "anonymous", + } satisfies TimelineEditionEntry; + }), + ]; +}; + +export const createFallbackTimelineEdition = ( + activity: GitHubActivity, + allowedPublicRepositories: ReadonlySet, + now = new Date() +): TimelineEdition | null => { + const end = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + ); + const windowEnd = dateOnly(end); + const windowStart = dateOnly(addUtcDays(end, -(TIMELINE_WINDOW_DAYS - 1))); + const curated = timelineEntries.flatMap((entry) => { + if (entry.date < windowStart || entry.date > windowEnd) { + return []; + } + const githubRepositoryUrl = githubRepositoryUrlFrom(entry.href); + if ( + githubRepositoryUrl !== null && + !allowedPublicRepositories.has(githubRepositoryUrl) + ) { + return []; + } + + const sourceKey = `curated:${digestTimelineValue({ date: entry.date, title: entry.title }).slice(0, 24)}`; + const isPrivate = "private" in entry && entry.private; + const importance = entry.importance ?? "story"; + return [ + { + bucket: entry.bucket, + cadence: importance === "lead" ? "clustered" : "isolated", + description: entry.description, + endDate: isPrivate ? `${entry.date.slice(0, 7)}-01` : entry.date, + ...(isPrivate ? {} : { href: entry.href, label: entry.label }), + id: `${slug(entry.title)}-${entry.date}`, + importance, + kind: "project", + metrics: [], + sourceKeys: [sourceKey], + startDate: isPrivate ? `${entry.date.slice(0, 7)}-01` : entry.date, + title: entry.title, + visibility: isPrivate ? "private" : "public", + } satisfies TimelineEditionEntry, + ]; + }); + const activityEntries = fallbackActivityEntries(activity, windowStart); + const planResult = timelinePlanSchema.safeParse({ + entries: [...curated, ...activityEntries].toSorted((left, right) => + right.startDate.localeCompare(left.startDate) + ), + headline: "The work, along one line.", + standfirst: + "A rolling edition of public milestones, sustained runs, and the smaller acts of collaboration between them.", + windowEnd, + windowStart, + }); + if (!planResult.success) { + return null; + } + const plan = planResult.data; + const sourceDigest = digestTimelineValue({ + activity, + sources: plan.entries.flatMap((entry) => entry.sourceKeys), + }); + const editionKey = digestTimelineValue({ plan, sourceDigest }); + + return timelineEditionSchema.parse({ + ...plan, + editionKey, + generatedAt: now.toISOString(), + promptVersion: TIMELINE_PROMPT_VERSION, + schemaVersion: TIMELINE_SCHEMA_VERSION, + sourceDigest, + }); +}; diff --git a/src/lib/timeline-github.ts b/src/lib/timeline-github.ts new file mode 100644 index 0000000..815f601 --- /dev/null +++ b/src/lib/timeline-github.ts @@ -0,0 +1,1717 @@ +import { createHash, createPrivateKey, createSign } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; + +import { isTimelineDatabaseConfigured } from "@/db/client"; +import { fetchPublicGitHubContributionDays } from "@/lib/github-contribution-calendar"; +import { TIMELINE_WINDOW_DAYS } from "@/lib/timeline-core"; +import { + normalizeGitHubContributionSlice, + normalizeTimelinePrivacyKey, + parsePrivateTimelineTaxonomy, +} from "@/lib/timeline-privacy"; +import type { PrivateTaxonomyValue } from "@/lib/timeline-privacy"; +import { + beginTimelineSyncRun, + completeTimelineSyncRun, + countStoredTimelineActivity, + deleteTimelineActivityByIds, + deleteTimelinePublicEventsByIds, + failTimelineSyncRun, + pruneTimelineActivityBefore, + pruneTimelineContributionTotalsBefore, + pruneTimelinePublicEventsBefore, + readLastCompletedTimelineSync, + readTimelineActivityDays, + readTimelinePublicEvents, + rejectPublishedTimelineEditions, + upsertTimelineActivityDays, + upsertTimelineContributionTotals, + upsertTimelinePublicEvents, +} from "@/lib/timeline-store"; +import type { + TimelineActivityDayRecord, + TimelineContributionTotalRecord, + TimelinePublicEventRecord, +} from "@/lib/timeline-store"; + +const GITHUB_LOGIN = "f0rr0"; +const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"; +const GITHUB_API_URL = "https://api.github.com"; +const GITHUB_API_VERSION = "2026-03-10"; +const GITHUB_TIMEOUT_MS = 15_000; +const GITHUB_REST_INTERVAL_MS = 125; +const GITHUB_MAX_RETRY_DELAY_MS = 10_000; +const GITHUB_MAX_RETRIES = 2; +const GITHUB_MAX_PAGES = 1000; +const INCREMENTAL_LOOKBACK_DAYS = 21; +const RETENTION_DAYS = 420; + +const contributionQuery = ` + query TimelineContributionSlice( + $login: String! + $from: DateTime! + $to: DateTime! + $includeCommits: Boolean! + $includeIssues: Boolean! + $includePullRequests: Boolean! + $includeReviews: Boolean! + $includeRepositories: Boolean! + $issueCursor: String + $pullRequestCursor: String + $reviewCursor: String + $repositoryCursor: String + ) { + user(login: $login) { + contributionsCollection(from: $from, to: $to) { + commitContributionsByRepository(maxRepositories: 100) + @include(if: $includeCommits) { + repository { + id + nameWithOwner + isPrivate + url + description + primaryLanguage { + name + } + repositoryTopics(first: 10) { + nodes { + topic { + name + } + } + } + } + contributions(first: 100) { + nodes { + occurredAt + commitCount + } + } + } + issueContributions(first: 100, after: $issueCursor) + @include(if: $includeIssues) { + nodes { + isRestricted + occurredAt + issue { + id + title + url + repository { + ...TimelinePublicRepository + } + } + } + pageInfo { + endCursor + hasNextPage + } + } + pullRequestContributions(first: 100, after: $pullRequestCursor) + @include(if: $includePullRequests) { + nodes { + isRestricted + occurredAt + pullRequest { + id + title + url + repository { + ...TimelinePublicRepository + } + } + } + pageInfo { + endCursor + hasNextPage + } + } + pullRequestReviewContributions(first: 100, after: $reviewCursor) + @include(if: $includeReviews) { + nodes { + isRestricted + occurredAt + pullRequest { + id + title + url + repository { + ...TimelinePublicRepository + } + } + } + pageInfo { + endCursor + hasNextPage + } + } + repositoryContributions(first: 100, after: $repositoryCursor) + @include(if: $includeRepositories) { + nodes { + isRestricted + occurredAt + repository { + ...TimelinePublicRepository + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + } + + fragment TimelinePublicRepository on Repository { + id + nameWithOwner + isPrivate + url + description + primaryLanguage { + name + } + repositoryTopics(first: 10) { + nodes { + topic { + name + } + } + } + } +`; + +export type TimelineSyncKind = + | "backfill" + | "incremental" + | "manual" + | "webhook"; + +export interface TimelineSyncResult { + anonymousCoverage: "complete" | "unavailable"; + anonymousDays: number; + coverage: "complete" | "partial"; + events: number; + kind: TimelineSyncKind; + privateActivity: "included" | "skipped"; + rows: number; + windowEnd: string; + windowStart: string; +} + +class TimelineSyncError extends Error { + readonly code: string; + + constructor(code: string) { + super(code); + this.code = code; + this.name = "TimelineSyncError"; + } +} + +interface GitHubUserCredential { + kind: "user"; + token: string; +} + +interface GitHubInstallationCredential { + kind: "installation"; + token: string; +} + +type GitHubCredential = GitHubInstallationCredential | GitHubUserCredential; + +interface DateSlice { + end: string; + start: string; +} + +interface CollectionResult { + coverage: "complete" | "partial"; + failedRequests: number; + privateRecordsSkipped: number; + publicEventCoverage: "complete" | "partial" | "unavailable"; + publicEvents: TimelinePublicEventRecord[]; + records: TimelineActivityDayRecord[]; + successfulRequests: number; +} + +interface AnonymousContributionCollection { + coverage: "complete" | "unavailable"; + records: TimelineContributionTotalRecord[]; +} + +interface CollectionAccumulator { + coverage: "complete" | "partial"; + failedRequests: number; + privateRecordsSkipped: number; + publicEventCoverage: "complete" | "partial" | "unavailable"; + publicEvents: Map; + records: Map; + successfulRequests: number; +} + +interface NormalizationContext { + privacyKey: string | null; + subject: string; + taxonomy: ReadonlyMap; +} + +interface SyncPlan { + day: Date; + kind: TimelineSyncKind; + useFullWindow: boolean; + windowEnd: string; + windowStart: string; + windowStartDate: Date; +} + +interface GitHubRestPage { + nextUrl: string | null; + payload: unknown; + status: number; +} + +type GitHubRestClient = ( + url: string, + benignStatuses?: ReadonlySet +) => Promise; + +interface RestRepository { + apiUrl: string; + defaultBranch: string | null; + description: string | null; + htmlUrl: string; + id: string; + isPrivate: boolean; + language: string | null; + nameWithOwner: string; + topics: string[]; +} + +interface CommitObservation { + day: string; + reachedDefaultBranch: boolean; +} + +interface RepositoryCollectionResult { + coverage: "complete" | "partial"; + failedRequests: number; + normalized: ReturnType; + successfulRequests: number; +} + +interface PaginatedVisitResult { + pages: number; + status: number; +} + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const dateOnly = (date: Date) => date.toISOString().slice(0, 10); + +const addUtcDays = (date: Date, days: number) => + new Date( + Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate() + days + ) + ); + +const startOfUtcDay = (date: Date) => + new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) + ); + +const createMonthlySlices = (start: Date, end: Date): DateSlice[] => { + const slices: DateSlice[] = []; + let cursor = startOfUtcDay(start); + const finalDay = startOfUtcDay(end); + + while (cursor <= finalDay) { + const monthEnd = new Date( + Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 0) + ); + const sliceEnd = monthEnd < finalDay ? monthEnd : finalDay; + slices.push({ end: dateOnly(sliceEnd), start: dateOnly(cursor) }); + cursor = addUtcDays(sliceEnd, 1); + } + + return slices; +}; + +const base64Url = (value: string | Buffer) => + Buffer.from(value).toString("base64url"); + +const createGitHubAppJwt = (appId: string, privateKey: string) => { + const now = Math.floor(Date.now() / 1000); + const header = base64Url(JSON.stringify({ alg: "RS256", typ: "JWT" })); + const payload = base64Url( + JSON.stringify({ exp: now + 540, iat: now - 60, iss: appId }) + ); + const unsigned = `${header}.${payload}`; + const signer = createSign("RSA-SHA256"); + signer.update(unsigned); + signer.end(); + const signature = signer.sign( + createPrivateKey(privateKey.replaceAll("\\n", "\n")), + "base64url" + ); + return `${unsigned}.${signature}`; +}; + +const normalizedSetting = (value: string | undefined) => { + const normalized = value?.trim(); + return normalized === undefined || normalized.length === 0 + ? undefined + : normalized; +}; + +const writeSyncDiagnostic = (diagnostic: Record) => { + if (process.env.TIMELINE_SYNC_DIAGNOSTICS === "1") { + process.stderr.write(`${JSON.stringify(diagnostic)}\n`); + } +}; + +const readInstallationIds = (rawInstallationIds: string) => { + const installationIds = rawInstallationIds + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + if ( + installationIds.length === 0 || + installationIds.length > 20 || + installationIds.some((value) => !/^\d+$/.test(value)) + ) { + throw new TimelineSyncError("github-app-installations-invalid"); + } + return installationIds; +}; + +const fetchInstallationToken = async ( + appJwt: string, + installationId: string +) => { + const response = await fetch( + `${GITHUB_API_URL}/app/installations/${installationId}/access_tokens`, + { + body: JSON.stringify({ permissions: { contents: "read" } }), + cache: "no-store", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${appJwt}`, + "Content-Type": "application/json", + "User-Agent": "f0rr0.dev-timeline", + "X-GitHub-Api-Version": GITHUB_API_VERSION, + }, + method: "POST", + signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), + } + ); + + if (!response.ok) { + throw new TimelineSyncError("github-app-token-failed"); + } + + const payload: unknown = await response.json(); + if (!isObject(payload) || typeof payload.token !== "string") { + throw new TimelineSyncError("github-app-token-invalid"); + } + const token = payload.token.trim(); + if (token.length === 0) { + throw new TimelineSyncError("github-app-token-invalid"); + } + return token; +}; + +const readUserCredentials = (): GitHubUserCredential[] => { + const tokens = [ + process.env.GITHUB_PUBLIC_ACTIVITY_TOKEN, + process.env.GITHUB_ACTIVITY_TOKEN, + process.env.GITHUB_TOKEN, + process.env.GH_TOKEN, + ] + .map(normalizedSetting) + .filter((value): value is string => value !== undefined); + return [...new Set(tokens)].map((token) => ({ kind: "user", token })); +}; + +const readInstallationCredentials = async (): Promise< + GitHubInstallationCredential[] +> => { + const appId = normalizedSetting(process.env.GITHUB_APP_ID); + const privateKey = normalizedSetting(process.env.GITHUB_APP_PRIVATE_KEY); + const rawInstallationIds = normalizedSetting( + process.env.GITHUB_APP_INSTALLATION_IDS + ); + const hasAnyAppSetting = + appId !== undefined || + privateKey !== undefined || + rawInstallationIds !== undefined; + if (!hasAnyAppSetting) { + return []; + } + if ( + appId === undefined || + privateKey === undefined || + rawInstallationIds === undefined + ) { + throw new TimelineSyncError("github-app-configuration-incomplete"); + } + + let appJwt: string; + try { + appJwt = createGitHubAppJwt(appId, privateKey); + } catch { + throw new TimelineSyncError("github-app-private-key-invalid"); + } + + const credentials: GitHubInstallationCredential[] = []; + for (const installationId of readInstallationIds(rawInstallationIds)) { + credentials.push({ + kind: "installation", + token: await fetchInstallationToken(appJwt, installationId), + }); + } + return credentials; +}; + +const deduplicateCredentials = (credentials: readonly GitHubCredential[]) => { + const unique = new Map(); + for (const credential of credentials) { + const key = createHash("sha256") + .update(credential.kind) + .update("\0") + .update(credential.token) + .digest("hex"); + unique.set(key, credential); + } + return [...unique.values()]; +}; + +const readGitHubCredentials = async (): Promise => { + const userCredentials = readUserCredentials(); + const installationCredentials = await readInstallationCredentials(); + const credentials = deduplicateCredentials([ + ...userCredentials, + ...installationCredentials, + ]); + if (credentials.length === 0) { + throw new TimelineSyncError("github-credentials-missing"); + } + return credentials; +}; + +const contributionConnections = [ + { + connection: "issueContributions", + cursor: "issueCursor", + include: "includeIssues", + }, + { + connection: "pullRequestContributions", + cursor: "pullRequestCursor", + include: "includePullRequests", + }, + { + connection: "pullRequestReviewContributions", + cursor: "reviewCursor", + include: "includeReviews", + }, + { + connection: "repositoryContributions", + cursor: "repositoryCursor", + include: "includeRepositories", + }, +] as const; + +type ContributionConnection = (typeof contributionConnections)[number]; + +interface ContributionPage { + endCursor: string | null; + hasNextPage: boolean; + nodes: unknown[]; +} + +const contributionCollectionFromResponse = (payload: unknown) => { + if ( + !isObject(payload) || + !isObject(payload.data) || + !isObject(payload.data.user) || + !isObject(payload.data.user.contributionsCollection) + ) { + return null; + } + return payload.data.user.contributionsCollection; +}; + +const contributionPageFrom = ( + collection: Record, + connection: ContributionConnection["connection"] +): ContributionPage | null => { + const value = collection[connection]; + if ( + !isObject(value) || + !Array.isArray(value.nodes) || + !isObject(value.pageInfo) || + typeof value.pageInfo.hasNextPage !== "boolean" + ) { + return null; + } + const { endCursor, hasNextPage } = value.pageInfo; + if (endCursor !== null && typeof endCursor !== "string") { + return null; + } + return { + endCursor, + hasNextPage, + nodes: value.nodes, + }; +}; + +const fetchContributionPage = async ( + credential: GitHubCredential, + slice: DateSlice, + activeConnections: ReadonlySet, + cursors: ReadonlyMap, + includeCommits: boolean +) => { + const variables: Record = { + from: `${slice.start}T00:00:00Z`, + includeCommits, + login: GITHUB_LOGIN, + to: `${slice.end}T23:59:59Z`, + }; + for (const configuration of contributionConnections) { + variables[configuration.include] = activeConnections.has( + configuration.connection + ); + variables[configuration.cursor] = + cursors.get(configuration.connection) ?? null; + } + + const response = await fetch(GITHUB_GRAPHQL_URL, { + body: JSON.stringify({ + query: contributionQuery, + variables, + }), + cache: "no-store", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${credential.token}`, + "Content-Type": "application/json", + "User-Agent": "f0rr0.dev-timeline", + }, + method: "POST", + signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new TimelineSyncError(`github-graphql-http-${response.status}`); + } + + const payload: unknown = await response.json(); + if (isObject(payload) && Array.isArray(payload.errors)) { + writeSyncDiagnostic({ + errors: payload.errors.map((error) => + isObject(error) + ? { + path: Array.isArray(error.path) + ? error.path.filter( + (value): value is number | string => + typeof value === "number" || typeof value === "string" + ) + : null, + type: typeof error.type === "string" ? error.type : null, + } + : null + ), + phase: "graphql-response", + slice, + }); + } + return payload; +}; + +const allContributionConnections = new Set( + contributionConnections.map(({ connection }) => connection) +); + +const fetchContributionSlice = async ( + credential: GitHubCredential, + slice: DateSlice +) => { + const cursors = new Map< + ContributionConnection["connection"], + string | null + >(); + let activeConnections = allContributionConnections; + const payload = await fetchContributionPage( + credential, + slice, + activeConnections, + cursors, + true + ); + const mergedCollection = contributionCollectionFromResponse(payload); + if (mergedCollection === null) { + return payload; + } + + for (let pageNumber = 1; pageNumber < GITHUB_MAX_PAGES; pageNumber += 1) { + const nextConnections = new Set(); + for (const configuration of contributionConnections) { + if (!activeConnections.has(configuration.connection)) { + continue; + } + const page = contributionPageFrom( + mergedCollection, + configuration.connection + ); + if (page?.hasNextPage !== true) { + continue; + } + if (page.endCursor === null || page.endCursor.length === 0) { + return payload; + } + cursors.set(configuration.connection, page.endCursor); + nextConnections.add(configuration.connection); + } + if (nextConnections.size === 0) { + return payload; + } + + let nextPayload: unknown; + try { + nextPayload = await fetchContributionPage( + credential, + slice, + nextConnections, + cursors, + false + ); + } catch { + return payload; + } + const nextCollection = contributionCollectionFromResponse(nextPayload); + if (nextCollection === null) { + return payload; + } + for (const configuration of contributionConnections) { + if (!nextConnections.has(configuration.connection)) { + continue; + } + const mergedConnection = mergedCollection[configuration.connection]; + const nextConnection = nextCollection[configuration.connection]; + const nextPage = contributionPageFrom( + nextCollection, + configuration.connection + ); + if ( + !isObject(mergedConnection) || + !Array.isArray(mergedConnection.nodes) || + !isObject(nextConnection) || + nextPage === null + ) { + return payload; + } + mergedConnection.nodes.push(...nextPage.nodes); + mergedConnection.pageInfo = nextConnection.pageInfo; + } + activeConnections = nextConnections; + } + + return payload; +}; + +const createCollectionAccumulator = (): CollectionAccumulator => ({ + coverage: "complete", + failedRequests: 0, + privateRecordsSkipped: 0, + publicEventCoverage: "unavailable", + publicEvents: new Map(), + records: new Map(), + successfulRequests: 0, +}); + +const mergeRecord = ( + records: Map, + record: TimelineActivityDayRecord +) => { + const existing = records.get(record.id); + records.set(record.id, { + ...record, + commitCount: Math.max(existing?.commitCount ?? 0, record.commitCount), + reachedDefaultBranch: + record.reachedDefaultBranch || (existing?.reachedDefaultBranch ?? false), + }); +}; + +const mergeNormalizedSlice = ( + accumulator: CollectionAccumulator, + normalized: NonNullable> +) => { + accumulator.successfulRequests += 1; + accumulator.privateRecordsSkipped += normalized.privateRecordsSkipped; + if (normalized.coverage === "partial") { + accumulator.coverage = "partial"; + } + if (normalized.publicEventCoverage !== "unavailable") { + accumulator.publicEventCoverage = + normalized.publicEventCoverage === "partial" || + accumulator.publicEventCoverage === "partial" + ? "partial" + : "complete"; + } + for (const event of normalized.publicEvents) { + accumulator.publicEvents.set(event.id, event); + } + for (const record of normalized.records) { + mergeRecord(accumulator.records, record); + } +}; + +const markCollectionFailure = (accumulator: CollectionAccumulator) => { + accumulator.coverage = "partial"; + accumulator.failedRequests += 1; +}; + +const isFatalGitHubError = (error: unknown) => + error instanceof TimelineSyncError && + (error.code === "github-rate-limit-exhausted" || + error.code === "github-rest-http-401" || + error.code === "github-rest-http-403"); + +const finishCollection = ( + accumulator: CollectionAccumulator +): CollectionResult => ({ + coverage: accumulator.coverage, + failedRequests: accumulator.failedRequests, + privateRecordsSkipped: accumulator.privateRecordsSkipped, + publicEventCoverage: accumulator.publicEventCoverage, + publicEvents: [...accumulator.publicEvents.values()], + records: [...accumulator.records.values()], + successfulRequests: accumulator.successfulRequests, +}); + +const collectProfileContributions = async ( + credential: GitHubCredential, + slices: readonly DateSlice[], + context: NormalizationContext +): Promise => { + const accumulator = createCollectionAccumulator(); + for (const slice of slices) { + try { + const payload = await fetchContributionSlice(credential, slice); + const normalized = normalizeGitHubContributionSlice(payload, { + ...context, + windowEnd: slice.end, + windowStart: slice.start, + }); + if (normalized === null) { + writeSyncDiagnostic({ phase: "profile-normalization", slice }); + markCollectionFailure(accumulator); + } else { + if (normalized.coverage === "partial") { + writeSyncDiagnostic({ + eventCoverage: normalized.publicEventCoverage, + phase: "profile-coverage", + repositoriesSeen: normalized.repositoriesSeen, + slice, + }); + } + mergeNormalizedSlice(accumulator, normalized); + } + } catch (error) { + writeSyncDiagnostic({ + code: error instanceof TimelineSyncError ? error.code : "unexpected", + phase: "profile-request", + slice, + }); + markCollectionFailure(accumulator); + accumulator.publicEventCoverage = "partial"; + } + } + return finishCollection(accumulator); +}; + +const retryDelayFrom = (response: Response, attempt: number) => { + if (attempt >= GITHUB_MAX_RETRIES) { + return null; + } + + const retryAfter = response.headers.get("retry-after"); + if (retryAfter !== null) { + const seconds = Number(retryAfter); + return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null; + } + + if (response.headers.get("x-ratelimit-remaining") === "0") { + const reset = Number(response.headers.get("x-ratelimit-reset")); + return Number.isFinite(reset) + ? Math.max(0, reset * 1000 - Date.now()) + : null; + } + + return response.status === 429 || response.status >= 500 + ? 250 * 2 ** attempt + : null; +}; + +const createRequestPacer = () => { + let nextRequestAt = 0; + return async () => { + const wait = nextRequestAt - Date.now(); + if (wait > 0) { + await delay(wait); + } + nextRequestAt = Date.now() + GITHUB_REST_INTERVAL_MS; + }; +}; + +const fetchRestResponse = async ( + token: string, + url: string, + benignStatuses: ReadonlySet, + pace: () => Promise +) => { + for (let attempt = 0; attempt <= GITHUB_MAX_RETRIES; attempt += 1) { + await pace(); + const response = await fetch(url, { + cache: "no-store", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "User-Agent": "f0rr0.dev-timeline", + "X-GitHub-Api-Version": GITHUB_API_VERSION, + }, + signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), + }); + if (response.ok || benignStatuses.has(response.status)) { + return response; + } + + const retryDelay = retryDelayFrom(response, attempt); + if (retryDelay === null) { + throw new TimelineSyncError(`github-rest-http-${response.status}`); + } + if (retryDelay > GITHUB_MAX_RETRY_DELAY_MS) { + throw new TimelineSyncError("github-rate-limit-exhausted"); + } + await delay(retryDelay); + } + throw new TimelineSyncError("github-rest-unavailable"); +}; + +const nextLinkFrom = (header: string | null) => { + if (header === null) { + return null; + } + for (const link of header.split(",")) { + const match = /<([^>]+)>;\s*rel="([^"]+)"/.exec(link.trim()); + if (match?.[2] !== "next") { + continue; + } + const url = new URL(match[1]); + if ( + url.origin !== GITHUB_API_URL || + url.username.length > 0 || + url.password.length > 0 + ) { + throw new TimelineSyncError("github-pagination-invalid"); + } + return url.toString(); + } + return null; +}; + +const createGitHubRestClient = (token: string): GitHubRestClient => { + const pace = createRequestPacer(); + return async (url, benignStatuses = new Set()) => { + const response = await fetchRestResponse(token, url, benignStatuses, pace); + const payload = benignStatuses.has(response.status) + ? null + : ((await response.json()) as unknown); + return { + nextUrl: nextLinkFrom(response.headers.get("link")), + payload, + status: response.status, + }; + }; +}; + +const visitPaginatedItems = async ( + client: GitHubRestClient, + initialUrl: string, + itemsFrom: (payload: unknown) => unknown[] | null, + visit: (items: readonly unknown[]) => Promise | void, + benignStatuses: ReadonlySet = new Set() +): Promise => { + const visited = new Set(); + let nextUrl: string | null = initialUrl; + let pages = 0; + let status = 200; + + while (nextUrl !== null && pages < GITHUB_MAX_PAGES) { + if (visited.has(nextUrl)) { + throw new TimelineSyncError("github-pagination-cycle"); + } + visited.add(nextUrl); + const page = await client(nextUrl, benignStatuses); + const { nextUrl: followingUrl, payload, status: pageStatus } = page; + pages += 1; + status = pageStatus; + if (benignStatuses.has(status)) { + return { pages, status }; + } + const items = itemsFrom(payload); + if (items === null) { + throw new TimelineSyncError("github-rest-payload-invalid"); + } + await visit(items); + nextUrl = followingUrl; + } + + if (nextUrl !== null) { + throw new TimelineSyncError("github-pagination-limit"); + } + return { pages, status }; +}; + +const repositoryItemsFrom = (payload: unknown) => + isObject(payload) && Array.isArray(payload.repositories) + ? payload.repositories + : null; + +const commitItemsFrom = (payload: unknown) => + Array.isArray(payload) ? payload : null; + +const normalizedRepositoryApiUrl = (value: unknown, nameWithOwner: string) => { + if (typeof value !== "string") { + return null; + } + try { + const url = new URL(value); + const expectedPath = `/repos/${nameWithOwner}`.toLocaleLowerCase("en-US"); + return url.origin === GITHUB_API_URL && + url.pathname.toLocaleLowerCase("en-US") === expectedPath && + url.search.length === 0 && + url.hash.length === 0 + ? url.toString().replace(/\/$/, "") + : null; + } catch { + return null; + } +}; + +const restRepositoryFrom = (value: unknown): RestRepository | null => { + if (!isObject(value)) { + return null; + } + const id = + typeof value.node_id === "string" + ? value.node_id + : typeof value.id === "number" && Number.isSafeInteger(value.id) + ? String(value.id) + : null; + const nameWithOwner = + typeof value.full_name === "string" ? value.full_name : null; + if ( + id === null || + id.length === 0 || + id.length > 200 || + nameWithOwner === null || + !/^(?:[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?)\/[a-z\d._-]{1,100}$/i.test( + nameWithOwner + ) || + typeof value.private !== "boolean" || + typeof value.html_url !== "string" + ) { + return null; + } + const apiUrl = normalizedRepositoryApiUrl(value.url, nameWithOwner); + if (apiUrl === null) { + return null; + } + + return { + apiUrl, + defaultBranch: + typeof value.default_branch === "string" && + value.default_branch.length > 0 && + value.default_branch.length <= 255 + ? value.default_branch + : null, + description: + typeof value.description === "string" ? value.description : null, + htmlUrl: value.html_url, + id, + isPrivate: value.private, + language: typeof value.language === "string" ? value.language : null, + nameWithOwner, + topics: Array.isArray(value.topics) + ? value.topics + .filter((topic): topic is string => typeof topic === "string") + .slice(0, 10) + : [], + }; +}; + +const normalizedCommitDay = ( + value: unknown, + windowStart: string, + windowEnd: string +) => { + if (typeof value !== "string") { + return null; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + const day = parsed.toISOString().slice(0, 10); + return day >= windowStart && day <= windowEnd ? day : null; +}; + +const commitObservationFrom = ( + value: unknown, + repositoryId: string, + reachedDefaultBranch: boolean, + windowStart: string, + windowEnd: string +) => { + if (!isObject(value) || !isObject(value.commit)) { + return null; + } + const rawSha = value.sha; + if (typeof rawSha !== "string" || !/^[a-f\d]{40,64}$/i.test(rawSha)) { + return null; + } + const authorDate = isObject(value.commit.author) + ? value.commit.author.date + : null; + const committerDate = isObject(value.commit.committer) + ? value.commit.committer.date + : null; + const day = + normalizedCommitDay(authorDate, windowStart, windowEnd) ?? + normalizedCommitDay(committerDate, windowStart, windowEnd); + if (day === null) { + return null; + } + return { + key: createHash("sha256") + .update(repositoryId) + .update("\0") + .update(rawSha) + .digest("hex"), + observation: { day, reachedDefaultBranch } satisfies CommitObservation, + }; +}; + +const mergeCommitItems = ( + commitByKey: Map, + items: readonly unknown[], + repositoryId: string, + reachedDefaultBranch: boolean, + windowStart: string, + windowEnd: string +) => { + let invalidItems = 0; + for (const item of items) { + const parsed = commitObservationFrom( + item, + repositoryId, + reachedDefaultBranch, + windowStart, + windowEnd + ); + if (parsed === null) { + invalidItems += 1; + continue; + } + const existing = commitByKey.get(parsed.key); + commitByKey.set(parsed.key, { + day: parsed.observation.day, + reachedDefaultBranch: + parsed.observation.reachedDefaultBranch || + (existing?.reachedDefaultBranch ?? false), + }); + } + return invalidItems; +}; + +const commitUrlFor = ( + repository: RestRepository, + branch: string | null, + windowStart: string, + windowEnd: string +) => { + const url = new URL(`${repository.apiUrl}/commits`); + url.searchParams.set("author", GITHUB_LOGIN); + url.searchParams.set("per_page", "100"); + url.searchParams.set("since", `${windowStart}T00:00:00Z`); + url.searchParams.set("until", `${windowEnd}T23:59:59Z`); + if (branch !== null) { + url.searchParams.set("sha", branch); + } + return url.toString(); +}; + +const dailyNodesFrom = ( + commitByKey: ReadonlyMap +) => { + const byDay = new Map< + string, + { commitCount: number; reachedDefaultBranch: boolean } + >(); + for (const observation of commitByKey.values()) { + const current = byDay.get(observation.day); + byDay.set(observation.day, { + commitCount: (current?.commitCount ?? 0) + 1, + reachedDefaultBranch: + observation.reachedDefaultBranch || + (current?.reachedDefaultBranch ?? false), + }); + } + return [...byDay.entries()].map(([day, summary]) => ({ + commitCount: summary.commitCount, + occurredAt: `${day}T00:00:00Z`, + reachedDefaultBranch: summary.reachedDefaultBranch, + })); +}; + +const normalizeRestRepositoryActivity = ( + repository: RestRepository, + commitByKey: ReadonlyMap, + context: NormalizationContext, + windowStart: string, + windowEnd: string +) => + normalizeGitHubContributionSlice( + { + data: { + user: { + contributionsCollection: { + commitContributionsByRepository: [ + { + contributions: { nodes: dailyNodesFrom(commitByKey) }, + repository: { + description: repository.isPrivate + ? null + : repository.description, + id: repository.id, + isPrivate: repository.isPrivate, + nameWithOwner: repository.nameWithOwner, + primaryLanguage: + repository.isPrivate || repository.language === null + ? null + : { name: repository.language }, + repositoryTopics: { + nodes: repository.isPrivate + ? [] + : repository.topics.map((name) => ({ + topic: { name }, + })), + }, + url: repository.htmlUrl, + }, + }, + ], + }, + }, + }, + }, + { ...context, windowEnd, windowStart } + ); + +const collectRepositoryRef = async ( + client: GitHubRestClient, + repository: RestRepository, + branch: string | null, + reachedDefaultBranch: boolean, + optional: boolean, + commitByKey: Map, + windowStart: string, + windowEnd: string +) => { + let invalidItems = 0; + const result = await visitPaginatedItems( + client, + commitUrlFor(repository, branch, windowStart, windowEnd), + commitItemsFrom, + (items) => { + invalidItems += mergeCommitItems( + commitByKey, + items, + repository.id, + reachedDefaultBranch, + windowStart, + windowEnd + ); + }, + new Set(optional ? [404, 409] : [409]) + ); + return { invalidItems, requests: result.pages }; +}; + +const collectRepositoryActivity = async ( + client: GitHubRestClient, + repository: RestRepository, + context: NormalizationContext, + windowStart: string, + windowEnd: string +): Promise => { + if (repository.isPrivate && context.privacyKey === null) { + return { + coverage: "complete", + failedRequests: 0, + normalized: { + coverage: "complete", + privateRecordsSkipped: 1, + publicEventCoverage: "unavailable", + publicEvents: [], + records: [], + repositoriesSeen: 1, + }, + successfulRequests: 0, + }; + } + + const commitByKey = new Map(); + let coverage: "complete" | "partial" = "complete"; + let failedRequests = 0; + let successfulRequests = 0; + const refs = [ + { + branch: repository.defaultBranch, + optional: false, + reachedDefaultBranch: true, + }, + ...(repository.defaultBranch === "gh-pages" + ? [] + : [ + { + branch: "gh-pages", + optional: true, + reachedDefaultBranch: false, + }, + ]), + ]; + + for (const ref of refs) { + try { + const result = await collectRepositoryRef( + client, + repository, + ref.branch, + ref.reachedDefaultBranch, + ref.optional, + commitByKey, + windowStart, + windowEnd + ); + successfulRequests += result.requests; + if (result.invalidItems > 0) { + coverage = "partial"; + failedRequests += 1; + } + } catch (error) { + writeSyncDiagnostic({ + code: error instanceof TimelineSyncError ? error.code : "unexpected", + phase: "repository-ref", + private: repository.isPrivate, + ref: ref.reachedDefaultBranch ? "default" : "auxiliary", + }); + if (isFatalGitHubError(error)) { + throw error; + } + coverage = "partial"; + failedRequests += 1; + } + } + + return { + coverage, + failedRequests, + normalized: normalizeRestRepositoryActivity( + repository, + commitByKey, + context, + windowStart, + windowEnd + ), + successfulRequests, + }; +}; + +const absorbRepositoryResult = ( + accumulator: CollectionAccumulator, + result: RepositoryCollectionResult +) => { + accumulator.successfulRequests += result.successfulRequests; + accumulator.failedRequests += result.failedRequests; + if (result.coverage === "partial") { + accumulator.coverage = "partial"; + } + if (result.normalized === null) { + markCollectionFailure(accumulator); + return; + } + accumulator.privateRecordsSkipped += result.normalized.privateRecordsSkipped; + if (result.normalized.publicEventCoverage !== "unavailable") { + accumulator.publicEventCoverage = result.normalized.publicEventCoverage; + } + for (const event of result.normalized.publicEvents) { + accumulator.publicEvents.set(event.id, event); + } + for (const record of result.normalized.records) { + mergeRecord(accumulator.records, record); + } +}; + +const collectInstallationContributions = async ( + credential: GitHubInstallationCredential, + context: NormalizationContext, + windowStart: string, + windowEnd: string +): Promise => { + const accumulator = createCollectionAccumulator(); + const client = createGitHubRestClient(credential.token); + const url = new URL("/installation/repositories", GITHUB_API_URL); + url.searchParams.set("per_page", "100"); + + const inventory = await visitPaginatedItems( + client, + url.toString(), + repositoryItemsFrom, + async (items) => { + for (const item of items) { + const repository = restRepositoryFrom(item); + if (repository === null) { + writeSyncDiagnostic({ phase: "repository-normalization" }); + markCollectionFailure(accumulator); + continue; + } + const result = await collectRepositoryActivity( + client, + repository, + context, + windowStart, + windowEnd + ); + absorbRepositoryResult(accumulator, result); + } + } + ); + accumulator.successfulRequests += inventory.pages; + return finishCollection(accumulator); +}; + +const mergeCollection = ( + accumulator: CollectionAccumulator, + result: CollectionResult +) => { + accumulator.successfulRequests += result.successfulRequests; + accumulator.failedRequests += result.failedRequests; + accumulator.privateRecordsSkipped += result.privateRecordsSkipped; + if (result.publicEventCoverage !== "unavailable") { + accumulator.publicEventCoverage = + result.publicEventCoverage === "partial" || + accumulator.publicEventCoverage === "partial" + ? "partial" + : "complete"; + } + for (const event of result.publicEvents) { + accumulator.publicEvents.set(event.id, event); + } + if (result.coverage === "partial") { + accumulator.coverage = "partial"; + } + for (const record of result.records) { + mergeRecord(accumulator.records, record); + } +}; + +const collectTimelineActivity = async ( + credentials: readonly GitHubCredential[], + slices: readonly DateSlice[], + context: NormalizationContext, + windowStart: string, + windowEnd: string +) => { + const accumulator = createCollectionAccumulator(); + for (const credential of credentials) { + try { + const profileResult = await collectProfileContributions( + credential, + slices, + context + ); + mergeCollection(accumulator, profileResult); + + if (credential.kind === "installation") { + const repositoryResult = await collectInstallationContributions( + credential, + context, + windowStart, + windowEnd + ); + mergeCollection(accumulator, repositoryResult); + } + } catch (error) { + writeSyncDiagnostic({ + code: error instanceof TimelineSyncError ? error.code : "unexpected", + kind: credential.kind, + phase: "credential-collection", + }); + markCollectionFailure(accumulator); + } + } + + const result = finishCollection(accumulator); + if (result.successfulRequests === 0) { + throw new TimelineSyncError("github-activity-unavailable"); + } + return result; +}; + +const createSyncPlan = async (options: { + forceBackfill?: boolean; + kind?: TimelineSyncKind; + now?: Date; +}): Promise => { + const day = startOfUtcDay(options.now ?? new Date()); + const storedCount = await countStoredTimelineActivity(GITHUB_LOGIN); + const lastSync = await readLastCompletedTimelineSync(); + const scheduledWeeklyReconciliation = + options.kind !== "webhook" && day.getUTCDay() === 0; + const useFullWindow = + options.forceBackfill === true || + storedCount === 0 || + lastSync === null || + scheduledWeeklyReconciliation; + const kind = options.kind ?? (useFullWindow ? "backfill" : "incremental"); + const lookbackDays = useFullWindow + ? TIMELINE_WINDOW_DAYS + : INCREMENTAL_LOOKBACK_DAYS; + const windowStartDate = addUtcDays(day, -(lookbackDays - 1)); + return { + day, + kind, + useFullWindow, + windowEnd: dateOnly(day), + windowStart: dateOnly(windowStartDate), + windowStartDate, + }; +}; + +const collectAnonymousContributionTotals = async ( + plan: SyncPlan +): Promise => { + const windowStart = dateOnly( + addUtcDays(plan.day, -(TIMELINE_WINDOW_DAYS - 1)) + ); + try { + const days = await fetchPublicGitHubContributionDays({ + login: GITHUB_LOGIN, + windowEnd: plan.windowEnd, + windowStart, + }); + return { + coverage: "complete", + records: days.map(({ contributionCount, day }) => ({ + contributionCount, + day, + id: createHash("sha256") + .update(`github-public-calendar:${GITHUB_LOGIN}:${day}`) + .digest("hex"), + source: "github-public-calendar", + subject: GITHUB_LOGIN, + })), + }; + } catch { + writeSyncDiagnostic({ phase: "anonymous-contribution-calendar" }); + return { coverage: "unavailable", records: [] }; + } +}; + +const removeStaleActivity = async ( + records: readonly TimelineActivityDayRecord[], + privacyKey: string | null, + plan: SyncPlan +) => { + const existing = await readTimelineActivityDays( + GITHUB_LOGIN, + plan.windowStart, + plan.windowEnd + ); + const currentIds = new Set(records.map((record) => record.id)); + const staleIds = existing.flatMap((record) => { + if (record.source !== "github-profile" || currentIds.has(record.id)) { + return []; + } + if (record.visibility === "private" && privacyKey === null) { + return []; + } + return [record.id]; + }); + await deleteTimelineActivityByIds(staleIds); +}; + +const removeStalePublicEvents = async ( + records: readonly TimelinePublicEventRecord[], + windowStart: string, + windowEnd: string +) => { + const existing = await readTimelinePublicEvents( + GITHUB_LOGIN, + windowStart, + windowEnd + ); + const currentIds = new Set(records.map((record) => record.id)); + const staleIds = existing.flatMap((record) => + record.source === "github-profile" && !currentIds.has(record.id) + ? [record.id] + : [] + ); + await deleteTimelinePublicEventsByIds(staleIds); + return staleIds.length; +}; + +const persistCollection = async ( + collection: CollectionResult, + anonymousCollection: AnonymousContributionCollection, + privacyKey: string | null, + plan: SyncPlan, + runId: string +) => { + await Promise.all([ + upsertTimelineActivityDays(collection.records), + upsertTimelineContributionTotals(anonymousCollection.records), + upsertTimelinePublicEvents(collection.publicEvents), + ]); + if (collection.failedRequests === 0 && collection.coverage === "complete") { + await removeStaleActivity(collection.records, privacyKey, plan); + } + const retentionCutoff = dateOnly(addUtcDays(plan.day, -RETENTION_DAYS)); + // A permission-bound partial event view is still reconciled fail-closed: + // inaccessible identities are omitted rather than retained from an older run. + if (collection.publicEventCoverage !== "unavailable") { + const staleEventCount = await removeStalePublicEvents( + collection.publicEvents, + dateOnly(addUtcDays(plan.day, -(TIMELINE_WINDOW_DAYS - 1))), + plan.windowEnd + ); + if (staleEventCount > 0) { + await rejectPublishedTimelineEditions(); + } + } + await Promise.all([ + pruneTimelineActivityBefore(GITHUB_LOGIN, retentionCutoff), + pruneTimelineContributionTotalsBefore(GITHUB_LOGIN, retentionCutoff), + pruneTimelinePublicEventsBefore(GITHUB_LOGIN, retentionCutoff), + ]); + await completeTimelineSyncRun( + runId, + collection.records.length, + collection.publicEvents.length, + collection.publicEventCoverage, + anonymousCollection.records.length, + anonymousCollection.coverage, + collection.coverage + ); +}; + +const safeErrorCode = (error: unknown) => { + if (error instanceof TimelineSyncError) { + return error.code; + } + if (error instanceof Error && error.name === "TimeoutError") { + return "github-timeout"; + } + return "timeline-sync-failed"; +}; + +const executeTimelineSync = async (plan: SyncPlan, runId: string) => { + const credentials = await readGitHubCredentials(); + const privacyKey = normalizeTimelinePrivacyKey( + process.env.TIMELINE_PRIVACY_KEY + ); + const context: NormalizationContext = { + privacyKey, + subject: GITHUB_LOGIN, + taxonomy: parsePrivateTimelineTaxonomy( + process.env.TIMELINE_PRIVATE_TAXONOMY + ), + }; + const [collection, anonymousCollection] = await Promise.all([ + collectTimelineActivity( + credentials, + createMonthlySlices( + addUtcDays(plan.day, -(TIMELINE_WINDOW_DAYS - 1)), + plan.day + ), + context, + plan.windowStart, + plan.windowEnd + ), + collectAnonymousContributionTotals(plan), + ]); + await persistCollection( + collection, + anonymousCollection, + privacyKey, + plan, + runId + ); + + return { + anonymousCoverage: anonymousCollection.coverage, + anonymousDays: anonymousCollection.records.length, + coverage: collection.coverage, + events: collection.publicEvents.length, + kind: plan.kind, + privateActivity: + privacyKey === null || collection.privateRecordsSkipped > 0 + ? ("skipped" as const) + : ("included" as const), + rows: collection.records.length, + windowEnd: plan.windowEnd, + windowStart: plan.windowStart, + }; +}; + +export const syncGitHubTimeline = async ( + options: { + forceBackfill?: boolean; + kind?: TimelineSyncKind; + now?: Date; + } = {} +): Promise => { + if (!isTimelineDatabaseConfigured()) { + throw new TimelineSyncError("timeline-database-missing"); + } + + const plan = await createSyncPlan(options); + const runId = await beginTimelineSyncRun({ + fullWindow: plan.useFullWindow, + kind: plan.kind, + windowEnd: plan.windowEnd, + windowStart: plan.windowStart, + }); + + try { + return await executeTimelineSync(plan, runId); + } catch (error) { + await failTimelineSyncRun(runId, safeErrorCode(error)); + if (error instanceof TimelineSyncError) { + throw error; + } + throw new TimelineSyncError(safeErrorCode(error)); + } +}; diff --git a/src/lib/timeline-privacy.ts b/src/lib/timeline-privacy.ts new file mode 100644 index 0000000..4ee8904 --- /dev/null +++ b/src/lib/timeline-privacy.ts @@ -0,0 +1,656 @@ +import { createHash, createHmac } from "node:crypto"; + +import { workBucketSchema } from "@/lib/timeline-core"; +import type { WorkBucket } from "@/lib/timeline-core"; +import type { + TimelineActivityDayRecord, + TimelinePublicEventRecord, +} from "@/lib/timeline-store"; + +type JsonObject = Record; + +const githubRepositoryPattern = + /^(?:[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?)\/[a-z\d._-]{1,100}$/i; +const datePattern = /^\d{4}-\d{2}-\d{2}$/; +const maximumCommitCount = 100_000; + +export interface PrivateTaxonomyValue { + bucket: WorkBucket; + domain: string | null; +} + +export interface NormalizeGitHubSliceOptions { + privacyKey: string | null; + subject: string; + taxonomy: ReadonlyMap; + windowEnd: string; + windowStart: string; +} + +export interface NormalizedGitHubSlice { + coverage: "complete" | "partial"; + privateRecordsSkipped: number; + publicEventCoverage: "complete" | "partial" | "unavailable"; + publicEvents: TimelinePublicEventRecord[]; + records: TimelineActivityDayRecord[]; + repositoriesSeen: number; +} + +const isObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +const normalizeDate = (value: unknown) => { + if (typeof value !== "string") { + return null; + } + + const date = value.slice(0, 10); + if (!datePattern.test(date)) { + return null; + } + + const parsed = new Date(`${date}T00:00:00Z`); + return Number.isNaN(parsed.getTime()) || + parsed.toISOString().slice(0, 10) !== date + ? null + : date; +}; + +const normalizeText = (value: unknown, maximumLength: number) => { + if (typeof value !== "string") { + return null; + } + + const normalized = value.replaceAll(/\s+/g, " ").trim(); + return normalized.length === 0 ? null : normalized.slice(0, maximumLength); +}; + +const normalizeRepositoryName = (value: unknown) => { + const name = normalizeText(value, 140); + return name !== null && githubRepositoryPattern.test(name) ? name : null; +}; + +const normalizeGitHubUrl = (value: unknown, nameWithOwner: string) => { + if (typeof value !== "string") { + return null; + } + + try { + const url = new URL(value); + const normalizedPath = url.pathname.replace(/\/$/, "").toLowerCase(); + return url.protocol === "https:" && + url.hostname === "github.com" && + normalizedPath === `/${nameWithOwner.toLowerCase()}` && + url.search.length === 0 && + url.hash.length === 0 + ? `https://github.com/${nameWithOwner}` + : null; + } catch { + return null; + } +}; + +const normalizeGitHubEventUrl = ( + value: unknown, + nameWithOwner: string, + kind: TimelinePublicEventRecord["eventKind"] +) => { + if (kind === "repository_created") { + return normalizeGitHubUrl(value, nameWithOwner); + } + if (typeof value !== "string") { + return null; + } + + try { + const url = new URL(value); + const suffix = kind === "issue_opened" ? "issues" : "pull"; + const expectedPrefix = `/${nameWithOwner}/${suffix}/`.toLocaleLowerCase( + "en-US" + ); + const normalizedPath = url.pathname.toLocaleLowerCase("en-US"); + const number = normalizedPath.slice(expectedPrefix.length); + return url.protocol === "https:" && + url.hostname === "github.com" && + normalizedPath.startsWith(expectedPrefix) && + /^\d+$/.test(number) && + url.search.length === 0 && + url.hash.length === 0 + ? `https://github.com/${nameWithOwner}/${suffix}/${number}` + : null; + } catch { + return null; + } +}; + +const languageFamilyFor = (language: string | null) => { + const normalized = language?.toLocaleLowerCase("en-US") ?? ""; + + if ( + [ + "css", + "html", + "javascript", + "mdx", + "svelte", + "typescript", + "vue", + ].includes(normalized) + ) { + return "web"; + } + if (["c", "c++", "go", "rust", "swift", "zig"].includes(normalized)) { + return "systems"; + } + if ( + ["julia", "jupyter notebook", "python", "r", "sql"].includes(normalized) + ) { + return "data"; + } + if (["dart", "kotlin", "objective-c"].includes(normalized)) { + return "mobile"; + } + if ( + ["dockerfile", "hcl", "nix", "powershell", "shell"].includes(normalized) + ) { + return "infrastructure"; + } + if (["markdown", "tex", "typst"].includes(normalized)) { + return "documentation"; + } + return "other"; +}; + +const readTopics = (value: unknown) => { + if (!isObject(value) || !Array.isArray(value.nodes)) { + return []; + } + + return value.nodes.flatMap((node) => { + if (!isObject(node) || !isObject(node.topic)) { + return []; + } + const name = normalizeText(node.topic.name, 50)?.toLocaleLowerCase("en-US"); + return name === undefined || name === null ? [] : [name]; + }); +}; + +const classifyPublicBucket = (input: { + description: string | null; + nameWithOwner: string; + topics: readonly string[]; +}): Exclude => { + const haystack = [ + input.nameWithOwner, + input.description ?? "", + ...input.topics, + ] + .join(" ") + .toLocaleLowerCase("en-US"); + + if (/\b(ai|agent|agents|llm|mcp|model|models)\b/.test(haystack)) { + return "Applied AI"; + } + if ( + /\b(ci|database|devops|docker|infra|postgres|proxy|runtime|serverless)\b/.test( + haystack + ) + ) { + return "Infrastructure"; + } + if (/\b(portfolio|product|site|website|workflow)\b/.test(haystack)) { + return "Product systems"; + } + return "Open source"; +}; + +const sha256 = (value: string) => + createHash("sha256").update(value).digest("hex"); + +const hmac = (privacyKey: string, value: string) => + createHmac("sha256", privacyKey).update(value).digest("hex"); + +export const normalizeTimelinePrivacyKey = (value: string | undefined) => { + const normalized = value?.trim(); + return normalized !== undefined && + normalized.length >= 32 && + new Set(normalized).size >= 8 + ? normalized + : null; +}; + +export const timelinePrivacyPolicyVersion = ( + privacyKey: string, + taxonomy: ReadonlyMap +) => + hmac( + privacyKey, + JSON.stringify({ + policy: "timeline-private-v1", + taxonomy: [...taxonomy.entries()].toSorted(([left], [right]) => + left.localeCompare(right) + ), + }) + ); + +export const publicTimelineRepoKey = (repositoryId: string) => + sha256(`repository-node:${repositoryId}`); + +export const privateTimelineRepoKey = ( + repositoryId: string, + privacyKey: string +) => hmac(privacyKey, `repository:${repositoryId}`); + +const eventConnectionNames = new Set([ + "issueContributions", + "pullRequestContributions", + "pullRequestReviewContributions", + "repositoryContributions", +]); + +const isExpectedForbiddenEventError = (value: unknown) => { + if ( + !isObject(value) || + value.type !== "FORBIDDEN" || + !Array.isArray(value.path) + ) { + return false; + } + const { path } = value; + return ( + path.length === 5 && + path[0] === "user" && + path[1] === "contributionsCollection" && + typeof path[2] === "string" && + eventConnectionNames.has(path[2]) && + path[3] === "nodes" && + typeof path[4] === "number" && + Number.isSafeInteger(path[4]) && + path[4] >= 0 + ); +}; + +const contributionCollectionFrom = (payload: unknown) => { + if (!isObject(payload) || !isObject(payload.data)) { + return null; + } + const { errors } = payload; + const coverage = + errors === undefined || (Array.isArray(errors) && errors.length === 0) + ? "complete" + : Array.isArray(errors) && errors.every(isExpectedForbiddenEventError) + ? "partial" + : null; + if (coverage === null) { + return null; + } + + const { user } = payload.data; + if (!isObject(user) || !isObject(user.contributionsCollection)) { + return null; + } + + return { collection: user.contributionsCollection, coverage }; +}; + +const repositoryGroupsFrom = (collection: JsonObject) => { + const groups = collection.commitContributionsByRepository; + return Array.isArray(groups) ? groups : null; +}; + +const publicEventSpecs = [ + { + connection: "issueContributions", + entity: "issue", + eventKind: "issue_opened", + }, + { + connection: "pullRequestContributions", + entity: "pullRequest", + eventKind: "pull_request_opened", + }, + { + connection: "pullRequestReviewContributions", + entity: "pullRequest", + eventKind: "pull_request_reviewed", + }, + { + connection: "repositoryContributions", + entity: "repository", + eventKind: "repository_created", + }, +] as const satisfies readonly { + connection: string; + entity: string; + eventKind: TimelinePublicEventRecord["eventKind"]; +}[]; + +// The explicit guards are the public identity boundary for untrusted API data. +// eslint-disable-next-line complexity +const normalizePublicEvents = ( + collection: JsonObject, + options: NormalizeGitHubSliceOptions +) => { + const hasEventConnections = publicEventSpecs.some( + ({ connection }) => collection[connection] !== undefined + ); + if (!hasEventConnections) { + return { + coverage: "unavailable" as const, + events: [] as TimelinePublicEventRecord[], + }; + } + + let coverage: "complete" | "partial" = "complete"; + const events = new Map(); + for (const spec of publicEventSpecs) { + const connection = collection[spec.connection]; + if ( + !isObject(connection) || + !Array.isArray(connection.nodes) || + !isObject(connection.pageInfo) || + typeof connection.pageInfo.hasNextPage !== "boolean" + ) { + coverage = "partial"; + continue; + } + if (connection.pageInfo.hasNextPage) { + coverage = "partial"; + } + + for (const contribution of connection.nodes) { + if (!isObject(contribution) || contribution.isRestricted !== false) { + continue; + } + const entity = contribution[spec.entity]; + if (!isObject(entity)) { + continue; + } + const repository = + spec.eventKind === "repository_created" ? entity : entity.repository; + if (!isObject(repository) || repository.isPrivate !== false) { + continue; + } + + const repositoryId = normalizeText(repository.id, 200); + const nameWithOwner = normalizeRepositoryName(repository.nameWithOwner); + const day = normalizeDate(contribution.occurredAt); + // Review contributions represent the latest review for each distinct PR. + // Keying by the PR keeps later reviews as an update, not a duplicate event. + const entityId = normalizeText(entity.id, 200); + if ( + repositoryId === null || + nameWithOwner === null || + day === null || + day < options.windowStart || + day > options.windowEnd || + entityId === null + ) { + continue; + } + + const publicRepoUrl = normalizeGitHubUrl(repository.url, nameWithOwner); + const publicUrl = normalizeGitHubEventUrl( + entity.url, + nameWithOwner, + spec.eventKind + ); + const publicTitle = + spec.eventKind === "repository_created" + ? (nameWithOwner.split("/").at(-1) ?? null) + : normalizeText(entity.title, 240); + if ( + publicRepoUrl === null || + publicUrl === null || + publicTitle === null + ) { + continue; + } + + const id = sha256( + `${options.subject}:public-event:${spec.eventKind}:${entityId}` + ); + events.set(id, { + bucket: classifyPublicBucket({ + description: normalizeText(repository.description, 240), + nameWithOwner, + topics: readTopics(repository.repositoryTopics), + }), + day, + eventKind: spec.eventKind, + id, + publicRepoName: nameWithOwner, + publicRepoUrl, + publicTitle, + publicUrl, + repoKey: publicTimelineRepoKey(repositoryId), + source: "github-profile", + subject: options.subject, + }); + } + } + + return { coverage, events: [...events.values()] }; +}; + +const normalizeTaxonomyValue = ( + value: unknown +): PrivateTaxonomyValue | null => { + if (typeof value === "string") { + const bucket = workBucketSchema.safeParse(value); + return bucket.success && bucket.data !== "Across the work" + ? { bucket: bucket.data, domain: null } + : null; + } + + if (!isObject(value)) { + return null; + } + + const bucket = workBucketSchema.safeParse(value.bucket); + const rawDomain = normalizeText(value.domain, 64)?.toLocaleLowerCase("en-US"); + const domain = + rawDomain !== undefined && + rawDomain !== null && + /^[a-z\d][a-z\d-]{0,63}$/.test(rawDomain) + ? rawDomain + : null; + if (value.domain !== undefined && domain === null) { + return null; + } + return bucket.success && bucket.data !== "Across the work" + ? { bucket: bucket.data, domain } + : null; +}; + +export const parsePrivateTimelineTaxonomy = ( + value: string | undefined +): ReadonlyMap => { + if (value === undefined || value.trim().length === 0) { + return new Map(); + } + + let parsed: unknown; + try { + parsed = JSON.parse(value) as unknown; + } catch { + throw new Error("timeline-taxonomy-invalid"); + } + + if (!isObject(parsed)) { + throw new Error("timeline-taxonomy-invalid"); + } + + const taxonomy = new Map(); + for (const [repository, rawValue] of Object.entries(parsed)) { + const normalizedRepository = normalizeRepositoryName(repository); + const normalizedValue = normalizeTaxonomyValue(rawValue); + if (normalizedRepository === null || normalizedValue === null) { + throw new Error("timeline-taxonomy-invalid"); + } + taxonomy.set( + normalizedRepository.toLocaleLowerCase("en-US"), + normalizedValue + ); + } + + return taxonomy; +}; + +export const currentTimelinePrivacyPolicyVersion = () => { + const privacyKey = normalizeTimelinePrivacyKey( + process.env.TIMELINE_PRIVACY_KEY + ); + return privacyKey === null + ? null + : timelinePrivacyPolicyVersion( + privacyKey, + parsePrivateTimelineTaxonomy(process.env.TIMELINE_PRIVATE_TAXONOMY) + ); +}; + +// The explicit guards are the privacy boundary for an untrusted API payload. +// eslint-disable-next-line complexity +export const normalizeGitHubContributionSlice = ( + payload: unknown, + options: NormalizeGitHubSliceOptions +): NormalizedGitHubSlice | null => { + const response = contributionCollectionFrom(payload); + if (response === null) { + return null; + } + const { collection, coverage: responseCoverage } = response; + const groups = repositoryGroupsFrom(collection); + if (groups === null) { + return null; + } + + const records = new Map(); + let privateRecordsSkipped = 0; + let repositoriesSeen = 0; + + for (const group of groups) { + if (!isObject(group) || !isObject(group.repository)) { + continue; + } + + const { repository } = group; + const nameWithOwner = normalizeRepositoryName(repository.nameWithOwner); + const repositoryId = normalizeText(repository.id, 200); + const { isPrivate } = repository; + if ( + nameWithOwner === null || + repositoryId === null || + typeof isPrivate !== "boolean" || + !isObject(group.contributions) || + !Array.isArray(group.contributions.nodes) + ) { + continue; + } + + repositoriesSeen += 1; + const languageName = isObject(repository.primaryLanguage) + ? normalizeText(repository.primaryLanguage.name, 50) + : null; + const languageFamily = isPrivate + ? "withheld" + : languageFamilyFor(languageName); + const publicUrl = isPrivate + ? null + : normalizeGitHubUrl(repository.url, nameWithOwner); + if (!isPrivate && publicUrl === null) { + continue; + } + + const taxonomyValue = options.taxonomy.get( + nameWithOwner.toLocaleLowerCase("en-US") + ); + const bucket = isPrivate + ? (taxonomyValue?.bucket ?? "Private product work") + : classifyPublicBucket({ + description: normalizeText(repository.description, 240), + nameWithOwner, + topics: readTopics(repository.repositoryTopics), + }); + + const privatePrivacyKey = options.privacyKey; + if (isPrivate && privatePrivacyKey === null) { + privateRecordsSkipped += group.contributions.nodes.length; + continue; + } + + const repoKey = + isPrivate && privatePrivacyKey !== null + ? privateTimelineRepoKey(repositoryId, privatePrivacyKey) + : publicTimelineRepoKey(repositoryId); + const privacyDomainKey = + isPrivate && + privatePrivacyKey !== null && + taxonomyValue?.domain !== null && + taxonomyValue?.domain !== undefined + ? hmac(privatePrivacyKey, `domain:${taxonomyValue.domain}`) + : null; + + for (const contribution of group.contributions.nodes) { + if (!isObject(contribution)) { + continue; + } + + const day = normalizeDate(contribution.occurredAt); + const { commitCount } = contribution; + const reachedDefaultBranch = + typeof contribution.reachedDefaultBranch === "boolean" + ? contribution.reachedDefaultBranch + : true; + if ( + day === null || + day < options.windowStart || + day > options.windowEnd || + typeof commitCount !== "number" || + !Number.isSafeInteger(commitCount) || + commitCount <= 0 || + commitCount > maximumCommitCount + ) { + continue; + } + + const id = sha256(`${options.subject}:${repoKey}:${day}:github-profile`); + const existing = records.get(id); + const record: TimelineActivityDayRecord = { + bucket, + commitCount: Math.max(existing?.commitCount ?? 0, commitCount), + day, + id, + languageFamily, + privacyDomainKey, + privacyPolicyVersion: + isPrivate && privatePrivacyKey !== null + ? timelinePrivacyPolicyVersion(privatePrivacyKey, options.taxonomy) + : null, + publicRepoName: isPrivate ? null : nameWithOwner, + publicRepoUrl: publicUrl, + reachedDefaultBranch, + repoKey, + source: "github-profile", + subject: options.subject, + visibility: isPrivate ? "private" : "public", + }; + records.set(id, record); + } + } + + const publicEvents = normalizePublicEvents(collection, options); + const publicEventCoverage = + responseCoverage === "partial" ? "partial" : publicEvents.coverage; + return { + coverage: + groups.length >= 100 || publicEvents.coverage === "partial" + ? "partial" + : "complete", + privateRecordsSkipped, + publicEventCoverage, + publicEvents: publicEvents.events, + records: [...records.values()], + repositoriesSeen, + }; +}; diff --git a/src/lib/timeline-store.ts b/src/lib/timeline-store.ts new file mode 100644 index 0000000..da12f43 --- /dev/null +++ b/src/lib/timeline-store.ts @@ -0,0 +1,659 @@ +import { and, desc, eq, gte, inArray, lt, lte, sql } from "drizzle-orm"; + +import { getTimelineDatabase, isTimelineDatabaseConfigured } from "@/db/client"; +import { + timelineActivityDays, + timelineContributionTotals, + timelineEditions, + timelinePublicEvents, + timelineSyncRuns, + timelineWebhookReceipts, +} from "@/db/schema"; +import type { TimelinePublicEventKind } from "@/db/schema"; +import { + editionMatchesTimelinePrivacyPolicy, + timelineEditionSchema, +} from "@/lib/timeline-core"; +import type { TimelineEdition, WorkBucket } from "@/lib/timeline-core"; +import { currentTimelinePrivacyPolicyVersion } from "@/lib/timeline-privacy"; + +export interface TimelineActivityDayRecord { + bucket: WorkBucket; + commitCount: number; + day: string; + id: string; + languageFamily: string; + privacyDomainKey: string | null; + privacyPolicyVersion: string | null; + publicRepoName: string | null; + publicRepoUrl: string | null; + reachedDefaultBranch: boolean; + repoKey: string; + source: string; + subject: string; + visibility: "private" | "public"; +} + +export type StoredTimelineActivityDay = + typeof timelineActivityDays.$inferSelect; + +export interface TimelineContributionTotalRecord { + contributionCount: number; + day: string; + id: string; + source: "github-public-calendar"; + subject: string; +} + +export type StoredTimelineContributionTotal = + typeof timelineContributionTotals.$inferSelect; + +type PublicWorkBucket = Exclude< + WorkBucket, + "Across the work" | "Private product work" +>; + +export interface TimelinePublicEventRecord { + bucket: PublicWorkBucket; + day: string; + eventKind: TimelinePublicEventKind; + id: string; + publicRepoName: string; + publicRepoUrl: string; + publicTitle: string; + publicUrl: string; + repoKey: string; + source: "github-profile"; + subject: string; +} + +export type StoredTimelinePublicEvent = + typeof timelinePublicEvents.$inferSelect; + +const chunk = (values: readonly T[], size: number) => { + const chunks: T[][] = []; + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)); + } + return chunks; +}; + +export const readTimelineActivityDays = async ( + subject: string, + windowStart: string, + windowEnd: string +): Promise => { + if (!isTimelineDatabaseConfigured()) { + return []; + } + + return await getTimelineDatabase() + .select() + .from(timelineActivityDays) + .where( + and( + eq(timelineActivityDays.subject, subject), + gte(timelineActivityDays.day, windowStart), + lte(timelineActivityDays.day, windowEnd) + ) + ) + .orderBy(timelineActivityDays.day); +}; + +export const upsertTimelineActivityDays = async ( + records: readonly TimelineActivityDayRecord[] +) => { + if (records.length === 0) { + return 0; + } + + const database = getTimelineDatabase(); + for (const recordChunk of chunk(records, 250)) { + await database + .insert(timelineActivityDays) + .values(recordChunk) + .onConflictDoUpdate({ + set: { + bucket: sql`excluded.bucket`, + commitCount: sql`excluded.commit_count`, + languageFamily: sql`excluded.language_family`, + privacyDomainKey: sql`excluded.privacy_domain_key`, + privacyPolicyVersion: sql`excluded.privacy_policy_version`, + publicRepoName: sql`excluded.public_repo_name`, + publicRepoUrl: sql`excluded.public_repo_url`, + reachedDefaultBranch: sql`excluded.reached_default_branch`, + updatedAt: new Date(), + visibility: sql`excluded.visibility`, + }, + target: timelineActivityDays.id, + }); + } + + return records.length; +}; + +export const readTimelineContributionTotals = async ( + subject: string, + windowStart: string, + windowEnd: string +): Promise => { + if (!isTimelineDatabaseConfigured()) { + return []; + } + + return await getTimelineDatabase() + .select() + .from(timelineContributionTotals) + .where( + and( + eq(timelineContributionTotals.subject, subject), + gte(timelineContributionTotals.day, windowStart), + lte(timelineContributionTotals.day, windowEnd) + ) + ) + .orderBy(timelineContributionTotals.day); +}; + +export const upsertTimelineContributionTotals = async ( + records: readonly TimelineContributionTotalRecord[] +) => { + if (records.length === 0) { + return 0; + } + + const database = getTimelineDatabase(); + for (const recordChunk of chunk(records, 250)) { + await database + .insert(timelineContributionTotals) + .values(recordChunk) + .onConflictDoUpdate({ + set: { + contributionCount: sql`excluded.contribution_count`, + updatedAt: new Date(), + }, + target: timelineContributionTotals.id, + }); + } + + return records.length; +}; + +export const pruneTimelineActivityBefore = async ( + subject: string, + cutoff: string +) => { + if (!isTimelineDatabaseConfigured()) { + return; + } + + await getTimelineDatabase() + .delete(timelineActivityDays) + .where( + and( + eq(timelineActivityDays.subject, subject), + lt(timelineActivityDays.day, cutoff) + ) + ); +}; + +export const pruneTimelineContributionTotalsBefore = async ( + subject: string, + cutoff: string +) => { + if (!isTimelineDatabaseConfigured()) { + return; + } + + await getTimelineDatabase() + .delete(timelineContributionTotals) + .where( + and( + eq(timelineContributionTotals.subject, subject), + lt(timelineContributionTotals.day, cutoff) + ) + ); +}; + +export const readTimelinePublicEvents = async ( + subject: string, + windowStart: string, + windowEnd: string +): Promise => { + if (!isTimelineDatabaseConfigured()) { + return []; + } + + return await getTimelineDatabase() + .select() + .from(timelinePublicEvents) + .where( + and( + eq(timelinePublicEvents.subject, subject), + gte(timelinePublicEvents.day, windowStart), + lte(timelinePublicEvents.day, windowEnd) + ) + ) + .orderBy( + timelinePublicEvents.day, + timelinePublicEvents.eventKind, + timelinePublicEvents.id + ); +}; + +export const upsertTimelinePublicEvents = async ( + records: readonly TimelinePublicEventRecord[] +) => { + if (records.length === 0) { + return 0; + } + + const uniqueRecords = [ + ...new Map(records.map((record) => [record.id, record])).values(), + ]; + const database = getTimelineDatabase(); + for (const recordChunk of chunk(uniqueRecords, 250)) { + await database + .insert(timelinePublicEvents) + .values(recordChunk) + .onConflictDoUpdate({ + set: { + bucket: sql`excluded.bucket`, + day: sql`excluded.day`, + publicRepoName: sql`excluded.public_repo_name`, + publicRepoUrl: sql`excluded.public_repo_url`, + publicTitle: sql`excluded.public_title`, + publicUrl: sql`excluded.public_url`, + repoKey: sql`excluded.repo_key`, + source: sql`excluded.source`, + updatedAt: new Date(), + }, + target: timelinePublicEvents.id, + }); + } + + return uniqueRecords.length; +}; + +export const pruneTimelinePublicEventsBefore = async ( + subject: string, + cutoff: string +) => { + if (!isTimelineDatabaseConfigured()) { + return; + } + + await getTimelineDatabase() + .delete(timelinePublicEvents) + .where( + and( + eq(timelinePublicEvents.subject, subject), + lt(timelinePublicEvents.day, cutoff) + ) + ); +}; + +export const beginTimelineSyncRun = async (input: { + fullWindow: boolean; + kind: string; + windowEnd: string; + windowStart: string; +}) => { + const [run] = await getTimelineDatabase() + .insert(timelineSyncRuns) + .values(input) + .returning({ id: timelineSyncRuns.id }); + + if (run === undefined) { + throw new Error("Failed to create timeline sync run."); + } + + return run.id; +}; + +export const completeTimelineSyncRun = async ( + id: string, + rowCount: number, + eventCount: number, + publicEventCoverage: "complete" | "partial" | "unavailable", + anonymousDayCount: number, + anonymousCoverage: "complete" | "unavailable", + coverage: "complete" | "partial" +) => { + await getTimelineDatabase() + .update(timelineSyncRuns) + .set({ + completedAt: new Date(), + coverage, + errorCode: null, + eventCount, + anonymousCoverage, + anonymousDayCount, + publicEventCoverage, + rowCount, + status: "completed", + }) + .where(eq(timelineSyncRuns.id, id)); +}; + +export const failTimelineSyncRun = async (id: string, errorCode: string) => { + await getTimelineDatabase() + .update(timelineSyncRuns) + .set({ + completedAt: new Date(), + errorCode: errorCode.slice(0, 64), + status: "failed", + }) + .where(eq(timelineSyncRuns.id, id)); +}; + +export const readLastCompletedTimelineSync = async () => { + if (!isTimelineDatabaseConfigured()) { + return null; + } + + const [run] = await getTimelineDatabase() + .select({ + completedAt: timelineSyncRuns.completedAt, + windowEnd: timelineSyncRuns.windowEnd, + windowStart: timelineSyncRuns.windowStart, + }) + .from(timelineSyncRuns) + .where(eq(timelineSyncRuns.status, "completed")) + .orderBy(desc(timelineSyncRuns.completedAt)) + .limit(1); + + return run ?? null; +}; + +export const readLatestTimelineSync = async () => { + if (!isTimelineDatabaseConfigured()) { + return null; + } + + const [run] = await getTimelineDatabase() + .select({ + anonymousCoverage: timelineSyncRuns.anonymousCoverage, + completedAt: timelineSyncRuns.completedAt, + coverage: timelineSyncRuns.coverage, + startedAt: timelineSyncRuns.startedAt, + status: timelineSyncRuns.status, + }) + .from(timelineSyncRuns) + .orderBy(desc(timelineSyncRuns.startedAt)) + .limit(1); + + return run ?? null; +}; + +export const readLastCompleteAnonymousTimelineSync = async () => { + if (!isTimelineDatabaseConfigured()) { + return null; + } + + const [run] = await getTimelineDatabase() + .select({ + completedAt: timelineSyncRuns.completedAt, + windowEnd: timelineSyncRuns.windowEnd, + windowStart: timelineSyncRuns.windowStart, + }) + .from(timelineSyncRuns) + .where( + and( + eq(timelineSyncRuns.status, "completed"), + eq(timelineSyncRuns.anonymousCoverage, "complete") + ) + ) + .orderBy(desc(timelineSyncRuns.completedAt)) + .limit(1); + + return run ?? null; +}; + +export const readLastCompleteTimelineBackfill = async () => { + if (!isTimelineDatabaseConfigured()) { + return null; + } + + const [run] = await getTimelineDatabase() + .select({ + completedAt: timelineSyncRuns.completedAt, + windowEnd: timelineSyncRuns.windowEnd, + windowStart: timelineSyncRuns.windowStart, + }) + .from(timelineSyncRuns) + .where( + and( + eq(timelineSyncRuns.status, "completed"), + eq(timelineSyncRuns.coverage, "complete"), + eq(timelineSyncRuns.fullWindow, true) + ) + ) + .orderBy(desc(timelineSyncRuns.completedAt)) + .limit(1); + + return run ?? null; +}; + +export const publishTimelineEdition = async ( + edition: TimelineEdition, + agentModel: string +) => { + const validated = timelineEditionSchema.parse(edition); + const hasProtectedEntries = validated.entries.some( + (entry) => entry.visibility === "private" || entry.visibility === "mixed" + ); + const privacyPolicyVersion = hasProtectedEntries + ? currentTimelinePrivacyPolicyVersion() + : null; + if (hasProtectedEntries && privacyPolicyVersion === null) { + throw new Error("timeline-privacy-policy-missing"); + } + const now = new Date(); + + await getTimelineDatabase() + .insert(timelineEditions) + .values({ + agentModel, + edition: validated, + editionKey: validated.editionKey, + publishedAt: now, + privacyPolicyVersion, + status: "published", + windowEnd: validated.windowEnd, + windowStart: validated.windowStart, + }) + .onConflictDoUpdate({ + set: { + agentModel, + edition: validated, + publishedAt: now, + privacyPolicyVersion, + status: "published", + updatedAt: now, + }, + target: timelineEditions.editionKey, + }); + + return validated.editionKey; +}; + +export const readPublishedTimelineEdition = async () => { + if (!isTimelineDatabaseConfigured()) { + return null; + } + + const [row] = await getTimelineDatabase() + .select({ + edition: timelineEditions.edition, + privacyPolicyVersion: timelineEditions.privacyPolicyVersion, + }) + .from(timelineEditions) + .where(eq(timelineEditions.status, "published")) + .orderBy(desc(timelineEditions.publishedAt)) + .limit(1); + + const result = timelineEditionSchema.safeParse(row?.edition); + if (!result.success) { + return null; + } + + if ( + !editionMatchesTimelinePrivacyPolicy( + result.data, + row?.privacyPolicyVersion ?? null, + currentTimelinePrivacyPolicyVersion() + ) + ) { + return null; + } + + return result.data; +}; + +export const recordTimelineWebhookReceipt = async (input: { + deliveryKey: string; + eventType: string; + expiresAt: Date; +}) => { + const rows = await getTimelineDatabase() + .insert(timelineWebhookReceipts) + .values({ + ...input, + status: "accepted", + }) + .onConflictDoNothing() + .returning({ deliveryKey: timelineWebhookReceipts.deliveryKey }); + + return rows.length === 1; +}; + +export const markTimelineWebhookProcessed = async (deliveryKey: string) => { + await getTimelineDatabase() + .update(timelineWebhookReceipts) + .set({ processedAt: new Date(), status: "processed" }) + .where(eq(timelineWebhookReceipts.deliveryKey, deliveryKey)); +}; + +export const pruneTimelineWebhookReceipts = async (now = new Date()) => { + if (!isTimelineDatabaseConfigured()) { + return; + } + + await getTimelineDatabase() + .delete(timelineWebhookReceipts) + .where(lt(timelineWebhookReceipts.expiresAt, now)); +}; + +export const countStoredTimelineActivity = async (subject: string) => { + if (!isTimelineDatabaseConfigured()) { + return 0; + } + + const [row] = await getTimelineDatabase() + .select({ count: sql`count(*)::int` }) + .from(timelineActivityDays) + .where(eq(timelineActivityDays.subject, subject)); + + return row?.count ?? 0; +}; + +export const countStoredTimelinePublicEvents = async (subject: string) => { + if (!isTimelineDatabaseConfigured()) { + return 0; + } + + const [row] = await getTimelineDatabase() + .select({ count: sql`count(*)::int` }) + .from(timelinePublicEvents) + .where(eq(timelinePublicEvents.subject, subject)); + + return row?.count ?? 0; +}; + +export const deleteTimelineActivityByIds = async (ids: readonly string[]) => { + if (ids.length === 0 || !isTimelineDatabaseConfigured()) { + return; + } + + for (const idChunk of chunk(ids, 500)) { + await getTimelineDatabase() + .delete(timelineActivityDays) + .where(inArray(timelineActivityDays.id, idChunk)); + } +}; + +export const deleteTimelinePublicEventsByIds = async ( + ids: readonly string[] +) => { + if (ids.length === 0 || !isTimelineDatabaseConfigured()) { + return; + } + + for (const idChunk of chunk(ids, 500)) { + await getTimelineDatabase() + .delete(timelinePublicEvents) + .where(inArray(timelinePublicEvents.id, idChunk)); + } +}; + +export const deleteTimelineActivityByRepoKey = async ( + subject: string, + repoKey: string +) => { + if (!isTimelineDatabaseConfigured()) { + return; + } + + await getTimelineDatabase() + .delete(timelineActivityDays) + .where( + and( + eq(timelineActivityDays.subject, subject), + eq(timelineActivityDays.repoKey, repoKey) + ) + ); +}; + +export const deleteTimelinePublicEventsByRepoKey = async ( + subject: string, + repoKey: string +) => { + if (!isTimelineDatabaseConfigured()) { + return; + } + + await getTimelineDatabase() + .delete(timelinePublicEvents) + .where( + and( + eq(timelinePublicEvents.subject, subject), + eq(timelinePublicEvents.repoKey, repoKey) + ) + ); +}; + +export const deletePrivateTimelineActivity = async (subject: string) => { + if (!isTimelineDatabaseConfigured()) { + return; + } + + await getTimelineDatabase() + .delete(timelineActivityDays) + .where( + and( + eq(timelineActivityDays.subject, subject), + eq(timelineActivityDays.visibility, "private") + ) + ); +}; + +export const rejectPublishedTimelineEditions = async () => { + if (!isTimelineDatabaseConfigured()) { + return; + } + + await getTimelineDatabase() + .update(timelineEditions) + .set({ status: "rejected", updatedAt: new Date() }) + .where(eq(timelineEditions.status, "published")); +}; diff --git a/src/lib/timeline-webhook.ts b/src/lib/timeline-webhook.ts new file mode 100644 index 0000000..c353469 --- /dev/null +++ b/src/lib/timeline-webhook.ts @@ -0,0 +1,79 @@ +import { + privateTimelineRepoKey, + publicTimelineRepoKey, +} from "@/lib/timeline-privacy"; + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const safeNodeId = (value: unknown) => + typeof value === "string" && /^[A-Za-z\d_=:-]{4,200}$/.test(value) + ? value + : null; + +export interface TimelineWebhookRevocation { + repoKeys: string[]; + withdrawAllPrivateActivity: boolean; +} + +export const timelineRevocationFromWebhook = ( + body: string, + eventType: string, + privacyKey: string | null +): TimelineWebhookRevocation => { + let payload: unknown; + try { + payload = JSON.parse(body) as unknown; + } catch { + return { repoKeys: [], withdrawAllPrivateActivity: false }; + } + if (!isObject(payload)) { + return { repoKeys: [], withdrawAllPrivateActivity: false }; + } + + const nodeIds = new Set(); + const action = typeof payload.action === "string" ? payload.action : ""; + const withdrawAllPrivateActivity = + eventType === "installation" && + (action === "deleted" || action === "suspend"); + if (eventType === "installation_repositories") { + const removed = payload.repositories_removed; + if (Array.isArray(removed)) { + for (const repository of removed) { + if (isObject(repository)) { + const nodeId = safeNodeId(repository.node_id); + if (nodeId !== null) { + nodeIds.add(nodeId); + } + } + } + } + } + + const { repository } = payload; + if ( + isObject(repository) && + (repository.private === true || + repository.visibility === "private" || + action === "deleted") + ) { + const nodeId = safeNodeId(repository.node_id); + if (nodeId !== null) { + nodeIds.add(nodeId); + } + } + + const repoKeys = new Set(); + for (const nodeId of nodeIds) { + repoKeys.add(publicTimelineRepoKey(nodeId)); + if (privacyKey !== null) { + repoKeys.add(privateTimelineRepoKey(nodeId, privacyKey)); + } + } + + return { + repoKeys: [...repoKeys], + withdrawAllPrivateActivity: + withdrawAllPrivateActivity || (nodeIds.size > 0 && privacyKey === null), + }; +}; diff --git a/src/lib/timeline.ts b/src/lib/timeline.ts new file mode 100644 index 0000000..57b0216 --- /dev/null +++ b/src/lib/timeline.ts @@ -0,0 +1,46 @@ +import "server-only"; +import { unstable_cache } from "next/cache"; + +import type { GitHubProfile } from "@/lib/github-profile-core"; +import type { TimelineEdition } from "@/lib/timeline-core"; +import { createFallbackTimelineEdition } from "@/lib/timeline-fallback"; +import { readPublishedTimelineEdition } from "@/lib/timeline-store"; + +const readCachedPublishedEdition = unstable_cache( + async () => { + try { + return await readPublishedTimelineEdition(); + } catch { + return null; + } + }, + ["published-timeline-edition-v2"], + { revalidate: 900, tags: ["timeline-edition"] } +); + +export const getPublishedTimelineEdition = async () => + await readCachedPublishedEdition(); + +export const resolveTimelineEdition = ( + github: GitHubProfile, + published: TimelineEdition | null, + now = new Date() +): TimelineEdition | null => { + const allowedPublicRepositories = new Set( + github.status === "available" + ? github.projects.map((project) => project.url.toLocaleLowerCase("en-US")) + : [] + ); + // Published links already crossed the event-ingestion and edition-validator + // boundaries. The daily full public-event reconciliation withdraws an + // edition if a previously visible GitHub object disappears. + if (published !== null) { + return published; + } + + return createFallbackTimelineEdition( + github.activity, + allowedPublicRepositories, + now + ); +}; diff --git a/src/types/server-only.d.ts b/src/types/server-only.d.ts new file mode 100644 index 0000000..06c3049 --- /dev/null +++ b/src/types/server-only.d.ts @@ -0,0 +1 @@ +declare module "server-only"; diff --git a/tests/github-profile.test.mjs b/tests/github-profile.test.mjs new file mode 100644 index 0000000..27fd39b --- /dev/null +++ b/tests/github-profile.test.mjs @@ -0,0 +1,348 @@ +import { describe, expect, test } from "bun:test"; + +import { + createGitHubContributionWindow, + createUnavailableGitHubProfile, + parseGitHubContributionCalendarDays, + parseGitHubContributionCalendarHtml, + parseGitHubProfileResponse, + parseGitHubRepositoriesResponse, +} from "../src/lib/github-profile-core.ts"; + +const window = { + from: "2025-08-14T00:00:00.000Z", + to: "2026-08-12T12:00:00.000Z", +}; + +const contributionDay = (date, contributionCount) => ({ + contributionCount, + date, +}); + +const publicRepository = (overrides = {}) => ({ + description: "A useful public project.", + forkCount: 4, + isFork: false, + isPrivate: false, + name: "public-project", + owner: { login: "f0rr0" }, + primaryLanguage: { color: "#3178c6", name: "TypeScript" }, + repositoryTopics: { + nodes: [{ topic: { name: "Next-JS" } }, { topic: { name: "portfolio" } }], + }, + stargazerCount: 42, + updatedAt: "2026-08-11T10:00:00Z", + url: "https://github.com/f0rr0/public-project", + ...overrides, +}); + +const responseWith = (repositories) => ({ + data: { + user: { + contributionsCollection: { + contributionCalendar: { + totalContributions: 11, + weeks: [ + { + contributionDays: [ + contributionDay("2025-08-13", 50), + contributionDay("2025-08-14", 2), + contributionDay("2025-08-15", 0), + ], + firstDay: "2025-08-10", + }, + { + contributionDays: [ + contributionDay("2025-08-17", 1), + contributionDay("2025-08-18", 8), + ], + firstDay: "2025-08-17", + }, + ], + }, + restrictedContributionsCount: 7, + }, + login: "f0rr0", + repositories: { nodes: repositories }, + }, + }, +}); + +const contributionCalendarDocument = (days) => { + const tooltips = days + .map(({ count, date, id }) => { + const label = + count === 0 + ? `No contributions on ${date}.` + : `${count.toLocaleString("en-US")} contribution${count === 1 ? "" : "s"} on ${date}.`; + return `${label}`; + }) + .join(""); + const cells = days + .map( + ({ date, id }) => + `` + ) + .join(""); + + return `${tooltips}${cells}
    `; +}; + +const restRepository = (overrides = {}) => ({ + description: "A REST project.", + fork: false, + forks_count: 3, + html_url: "https://github.com/f0rr0/rest-project", + language: "TypeScript", + name: "rest-project", + owner: { login: "f0rr0" }, + private: false, + stargazers_count: 12, + topics: ["next-js", "portfolio", "portfolio"], + updated_at: "2026-08-10T09:00:00Z", + ...overrides, +}); + +describe("GitHub profile normalization", () => { + test("creates a rolling 365-calendar-day UTC window", () => { + expect( + createGitHubContributionWindow(new Date("2026-08-12T12:34:56.000Z")) + ).toEqual({ + from: "2025-08-13T00:00:00.000Z", + to: "2026-08-12T12:34:56.000Z", + }); + }); + + test("reduces daily activity to coarse weekly aggregates", () => { + const profile = parseGitHubProfileResponse( + responseWith([publicRepository()]), + { + fetchedAt: "2026-08-12T12:00:00.000Z", + login: "f0rr0", + window, + } + ); + + expect(profile?.activity).toEqual({ + activeDays: 3, + from: "2025-08-14", + restrictedContributions: 7, + status: "available", + to: "2026-08-12", + totalContributions: 11, + weeks: [ + { contributionCount: 2, level: 1, weekStart: "2025-08-10" }, + { contributionCount: 9, level: 4, weekStart: "2025-08-17" }, + ], + }); + }); + + test("keeps only validated public, owned, non-fork repositories", () => { + const profile = parseGitHubProfileResponse( + responseWith([ + publicRepository(), + publicRepository({ + isPrivate: true, + name: "private-client-roadmap", + url: "https://github.com/f0rr0/private-client-roadmap", + }), + publicRepository({ + isFork: true, + name: "upstream-fork", + url: "https://github.com/f0rr0/upstream-fork", + }), + publicRepository({ + name: "someone-elses-project", + owner: { login: "another-user" }, + url: "https://github.com/another-user/someone-elses-project", + }), + ]), + { + fetchedAt: "2026-08-12T12:00:00.000Z", + login: "f0rr0", + window, + } + ); + + expect(profile?.projects).toEqual([ + { + description: "A useful public project.", + forks: 4, + language: "TypeScript", + languageColor: "#3178c6", + name: "public-project", + stars: 42, + topics: ["next-js", "portfolio"], + updatedAt: "2026-08-11T10:00:00.000Z", + url: "https://github.com/f0rr0/public-project", + }, + ]); + expect(JSON.stringify(profile)).not.toContain("private-client-roadmap"); + expect(JSON.stringify(profile)).not.toContain("upstream-fork"); + }); + + test("rejects GraphQL errors and malformed activity totals", () => { + expect( + parseGitHubProfileResponse( + { errors: [{ message: "Bad credentials" }] }, + { + fetchedAt: "2026-08-12T12:00:00.000Z", + login: "f0rr0", + window, + } + ) + ).toBeNull(); + + const malformed = responseWith([publicRepository()]); + malformed.data.user.contributionsCollection.contributionCalendar.totalContributions = + -1; + + expect( + parseGitHubProfileResponse(malformed, { + fetchedAt: "2026-08-12T12:00:00.000Z", + login: "f0rr0", + window, + }) + ).toBeNull(); + + const impossibleRestrictedTotal = responseWith([publicRepository()]); + impossibleRestrictedTotal.data.user.contributionsCollection.restrictedContributionsCount = 20; + + expect( + parseGitHubProfileResponse(impossibleRestrictedTotal, { + fetchedAt: "2026-08-12T12:00:00.000Z", + login: "f0rr0", + window, + }) + ).toBeNull(); + }); + + test("fallback activity never invents exact contribution totals", () => { + const profile = createUnavailableGitHubProfile({ + login: "f0rr0", + window, + }); + + expect(profile.status).toBe("unavailable"); + expect(profile.activity).toEqual({ + activeDays: null, + from: "2025-08-14", + restrictedContributions: null, + status: "unavailable", + to: "2026-08-12", + totalContributions: null, + weeks: [], + }); + expect(profile.projects.map((project) => project.name)).toEqual([ + "oliphaunt", + "react-native-rating", + ]); + }); + + test("parses public contribution HTML directly into rolling weekly totals", () => { + const priorYear = contributionCalendarDocument([ + { count: 1, date: "2025-12-29", id: "contribution-day-component-a" }, + { count: 0, date: "2025-12-30", id: "contribution-day-component-b" }, + { count: 2, date: "2025-12-31", id: "contribution-day-component-c" }, + ]); + const currentYear = contributionCalendarDocument([ + { count: 3, date: "2026-01-01", id: "contribution-day-component-d" }, + { count: 0, date: "2026-01-02", id: "contribution-day-component-e" }, + ]); + + expect( + parseGitHubContributionCalendarHtml([priorYear, currentYear], { + from: "2025-12-29T00:00:00.000Z", + to: "2026-01-02T12:00:00.000Z", + }) + ).toEqual({ + activeDays: 3, + from: "2025-12-29", + restrictedContributions: null, + status: "available", + to: "2026-01-02", + totalContributions: 6, + weeks: [ + { + contributionCount: 6, + level: 4, + weekStart: "2025-12-28", + }, + ], + }); + expect( + parseGitHubContributionCalendarDays([priorYear, currentYear], { + from: "2025-12-29T00:00:00.000Z", + to: "2026-01-02T12:00:00.000Z", + }) + ).toEqual([ + { contributionCount: 1, day: "2025-12-29" }, + { contributionCount: 0, day: "2025-12-30" }, + { contributionCount: 2, day: "2025-12-31" }, + { contributionCount: 3, day: "2026-01-01" }, + { contributionCount: 0, day: "2026-01-02" }, + ]); + }); + + test("rejects incomplete or malformed contribution HTML", () => { + const incomplete = contributionCalendarDocument([ + { count: 1, date: "2026-01-01", id: "contribution-day-component-a" }, + ]); + + expect( + parseGitHubContributionCalendarHtml([incomplete], { + from: "2026-01-01T00:00:00.000Z", + to: "2026-01-02T00:00:00.000Z", + }) + ).toBeNull(); + expect( + parseGitHubContributionCalendarHtml( + [ + ``, + ], + { + from: "2026-01-01T00:00:00.000Z", + to: "2026-01-01T00:00:00.000Z", + } + ) + ).toBeNull(); + }); + + test("normalizes only public owned non-fork REST repositories", () => { + const projects = parseGitHubRepositoriesResponse( + [ + restRepository(), + restRepository({ + html_url: "https://github.com/f0rr0/private-project", + name: "private-project", + private: true, + }), + restRepository({ + fork: true, + html_url: "https://github.com/f0rr0/a-fork", + name: "a-fork", + }), + restRepository({ + html_url: "https://github.com/someone-else/their-project", + name: "their-project", + owner: { login: "someone-else" }, + }), + ], + "f0rr0" + ); + + expect(projects).toEqual([ + { + description: "A REST project.", + forks: 3, + language: "TypeScript", + languageColor: null, + name: "rest-project", + stars: 12, + topics: ["next-js", "portfolio"], + updatedAt: "2026-08-10T09:00:00.000Z", + url: "https://github.com/f0rr0/rest-project", + }, + ]); + }); +}); diff --git a/tests/timeline.test.mjs b/tests/timeline.test.mjs new file mode 100644 index 0000000..a0cdbb7 --- /dev/null +++ b/tests/timeline.test.mjs @@ -0,0 +1,1107 @@ +import { describe, expect, test } from "bun:test"; + +import { + containsPrivateIdentifier, + createTimelineEdition, + editionMatchesTimelinePrivacyPolicy, + validateTimelinePlanAgainstDigest, +} from "../src/lib/timeline-core.ts"; +import { + calculateAnonymousContributionDays, + createTimelineActivityDigest, +} from "../src/lib/timeline-editorial.ts"; +import { createFallbackTimelineEdition } from "../src/lib/timeline-fallback.ts"; +import { + normalizeGitHubContributionSlice, + normalizeTimelinePrivacyKey, + parsePrivateTimelineTaxonomy, + privateTimelineRepoKey, + publicTimelineRepoKey, +} from "../src/lib/timeline-privacy.ts"; +import { timelineRevocationFromWebhook } from "../src/lib/timeline-webhook.ts"; + +const privacyKey = "0123456789abcdefFEDCBA9876543210timeline-safety-key"; +const windowStart = "2025-07-09"; +const windowEnd = "2026-08-12"; + +const repositoryGroup = ({ + commitCount = 4, + day = "2026-08-01T12:00:00Z", + id, + isPrivate, + nameWithOwner, + reachedDefaultBranch, +}) => ({ + contributions: { + nodes: [ + { + commitCount, + occurredAt: day, + ...(reachedDefaultBranch === undefined ? {} : { reachedDefaultBranch }), + }, + ], + }, + repository: { + description: isPrivate === true ? "Secret client roadmap" : "A public tool", + id, + isPrivate, + nameWithOwner, + primaryLanguage: { name: "TypeScript" }, + repositoryTopics: { + nodes: [{ topic: { name: isPrivate === true ? "secret" : "tooling" } }], + }, + url: `https://github.com/${nameWithOwner}`, + }, +}); + +const emptyConnection = () => ({ + nodes: [], + pageInfo: { hasNextPage: false }, +}); + +const contributionPayload = (groups, connections = {}) => ({ + data: { + user: { + contributionsCollection: { + commitContributionsByRepository: groups, + ...connections, + }, + }, + }, +}); + +const normalizeOptions = (overrides = {}) => ({ + privacyKey, + subject: "f0rr0", + taxonomy: parsePrivateTimelineTaxonomy( + JSON.stringify({ + "secret-org/stealth-client": { + bucket: "Applied AI", + domain: "Product", + }, + }) + ), + windowEnd, + windowStart, + ...overrides, +}); + +const storedRow = (overrides = {}) => ({ + bucket: "Private product work", + commitCount: 6, + day: "2026-07-02", + id: "a".repeat(64), + languageFamily: "withheld", + privacyDomainKey: "domain-a", + privacyPolicyVersion: "policy", + publicRepoName: null, + publicRepoUrl: null, + reachedDefaultBranch: true, + repoKey: "repo-a", + source: "github-profile", + subject: "f0rr0", + updatedAt: new Date("2026-08-12T00:00:00Z"), + visibility: "private", + ...overrides, +}); + +const activityCluster = (index, overrides = {}) => { + const date = `2026-0${index + 1}-0${index + 1}`; + return { + bucket: "Open source", + cadence: "clustered", + endDate: date, + facts: ["Several public changes landed together."], + key: `public:source-${index}`, + kind: "commit-run", + magnitude: "steady", + maxImportance: index === 0 ? "lead" : index < 2 ? "story" : "brief", + publicHref: `https://github.com/f0rr0/project-${index}`, + publicLabel: "View project", + publicTitle: `project-${index}`, + publishable: true, + rollupOf: [], + seriesKey: `series:project-${index}`, + startDate: date, + visibility: "public", + ...overrides, + }; +}; + +const digest = { + clusters: [ + activityCluster(0), + activityCluster(1), + activityCluster(2), + activityCluster(3), + activityCluster(4), + activityCluster(5, { + bucket: "Private product work", + endDate: "2026-07-01", + facts: ["A broad private work pattern was sustained."], + key: "private:source-six", + kind: "private-month", + magnitude: "sustained", + maxImportance: "story", + publicHref: undefined, + publicLabel: undefined, + publicTitle: undefined, + seriesKey: "private-series:general-work", + startDate: "2026-07-01", + visibility: "private", + }), + ], + coverage: "complete", + generatedAt: "2026-08-12T00:00:00.000Z", + windowEnd, + windowStart, +}; + +const candidatePlan = { + selections: digest.clusters.map((cluster, index) => ({ + importance: + index === 0 ? "lead" : index === 1 || index === 5 ? "story" : "brief", + sourceKey: cluster.key, + })), + windowEnd, + windowStart, +}; + +const storedPublicEvent = (overrides = {}) => ({ + bucket: "Open source", + day: "2026-08-01", + eventKind: "issue_opened", + id: "e".repeat(64), + publicRepoName: "upstream/public-tool", + publicRepoUrl: "https://github.com/upstream/public-tool", + publicTitle: "Document the edge case", + publicUrl: "https://github.com/upstream/public-tool/issues/42", + repoKey: "f".repeat(64), + source: "github-profile", + subject: "f0rr0", + updatedAt: new Date("2026-08-12T00:00:00Z"), + ...overrides, +}); + +const availableActivity = { + activeDays: 24, + from: "2025-08-13", + restrictedContributions: null, + status: "available", + to: "2026-08-12", + totalContributions: 72, + weeks: Array.from({ length: 10 }, (_, index) => ({ + contributionCount: index + 2, + level: 2, + weekStart: + index < 5 + ? `2025-${String(index + 8).padStart(2, "0")}-01` + : `2026-${String(index - 4).padStart(2, "0")}-01`, + })), +}; + +describe("timeline privacy boundary", () => { + test("discards private identity before creating storage records", () => { + const normalized = normalizeGitHubContributionSlice( + contributionPayload([ + repositoryGroup({ + id: "R_private_secret_identifier", + isPrivate: true, + nameWithOwner: "secret-org/stealth-client", + }), + repositoryGroup({ + id: "R_public_identifier", + isPrivate: false, + nameWithOwner: "f0rr0/public-tool", + }), + ]), + normalizeOptions() + ); + + expect(normalized).not.toBeNull(); + const privateRecord = normalized.records.find( + (record) => record.visibility === "private" + ); + expect(privateRecord).toMatchObject({ + bucket: "Applied AI", + languageFamily: "withheld", + publicRepoName: null, + publicRepoUrl: null, + visibility: "private", + }); + expect(privateRecord.privacyPolicyVersion).toHaveLength(64); + expect(JSON.stringify(privateRecord)).not.toContain("stealth-client"); + expect(JSON.stringify(privateRecord)).not.toContain( + "R_private_secret_identifier" + ); + }); + + test("fails closed for private rows without a valid privacy key", () => { + const normalized = normalizeGitHubContributionSlice( + contributionPayload([ + repositoryGroup({ + id: "R_private", + isPrivate: true, + nameWithOwner: "secret-org/stealth-client", + }), + ]), + normalizeOptions({ privacyKey: null }) + ); + + expect(normalized.records).toEqual([]); + expect(normalized.privateRecordsSkipped).toBe(1); + expect(normalizeTimelinePrivacyKey("x".repeat(64))).toBeNull(); + }); + + test("preserves an explicit non-default contribution branch marker", () => { + const normalized = normalizeGitHubContributionSlice( + contributionPayload([ + repositoryGroup({ + id: "R_public_gh_pages", + isPrivate: false, + nameWithOwner: "f0rr0/public-pages", + reachedDefaultBranch: false, + }), + ]), + normalizeOptions() + ); + + expect(normalized.records).toHaveLength(1); + expect(normalized.records[0].reachedDefaultBranch).toBe(false); + }); + + test("keeps verified public issue and pull-request events as exact evidence", () => { + const repository = { + description: "A public infrastructure tool", + id: "R_upstream_public_tool", + isPrivate: false, + nameWithOwner: "upstream/public-tool", + primaryLanguage: { name: "Go" }, + repositoryTopics: { nodes: [{ topic: { name: "infrastructure" } }] }, + url: "https://github.com/upstream/public-tool", + }; + const normalized = normalizeGitHubContributionSlice( + contributionPayload([], { + issueContributions: { + nodes: [ + { + isRestricted: false, + issue: { + id: "I_public_issue", + repository, + title: "Document the edge case", + url: "https://github.com/upstream/public-tool/issues/42", + }, + occurredAt: "2026-08-01T12:00:00Z", + }, + ], + pageInfo: { hasNextPage: false }, + }, + pullRequestContributions: { + nodes: [ + { + isRestricted: false, + occurredAt: "2026-08-02T12:00:00Z", + pullRequest: { + id: "PR_public_change", + repository, + title: "Handle an empty response", + url: "https://github.com/upstream/public-tool/pull/51", + }, + }, + ], + pageInfo: { hasNextPage: false }, + }, + pullRequestReviewContributions: emptyConnection(), + repositoryContributions: emptyConnection(), + }), + normalizeOptions() + ); + + expect(normalized.publicEventCoverage).toBe("complete"); + expect(normalized.publicEvents).toHaveLength(2); + expect(normalized.publicEvents[0]).toMatchObject({ + eventKind: "issue_opened", + publicRepoName: "upstream/public-tool", + publicTitle: "Document the edge case", + publicUrl: "https://github.com/upstream/public-tool/issues/42", + }); + expect(normalized.publicEvents[1]).toMatchObject({ + eventKind: "pull_request_opened", + publicTitle: "Handle an empty response", + publicUrl: "https://github.com/upstream/public-tool/pull/51", + }); + }); + + test("keeps accessible public events when GitHub forbids individual nodes", () => { + const repository = { + description: "A public infrastructure tool", + id: "R_upstream_public_tool", + isPrivate: false, + nameWithOwner: "upstream/public-tool", + primaryLanguage: { name: "Go" }, + repositoryTopics: { nodes: [] }, + url: "https://github.com/upstream/public-tool", + }; + const payload = contributionPayload([], { + issueContributions: emptyConnection(), + pullRequestContributions: { + nodes: [ + null, + { + isRestricted: false, + occurredAt: "2026-08-02T12:00:00Z", + pullRequest: { + id: "PR_accessible_change", + repository, + title: "Handle an empty response", + url: "https://github.com/upstream/public-tool/pull/51", + }, + }, + ], + pageInfo: { hasNextPage: false }, + }, + pullRequestReviewContributions: emptyConnection(), + repositoryContributions: emptyConnection(), + }); + payload.errors = [ + { + message: "intentionally ignored", + path: [ + "user", + "contributionsCollection", + "pullRequestContributions", + "nodes", + 0, + ], + type: "FORBIDDEN", + }, + ]; + + const normalized = normalizeGitHubContributionSlice( + payload, + normalizeOptions() + ); + + expect(normalized.coverage).toBe("complete"); + expect(normalized.publicEventCoverage).toBe("partial"); + expect(normalized.publicEvents).toHaveLength(1); + expect(normalized.publicEvents[0].publicTitle).toBe( + "Handle an empty response" + ); + expect(JSON.stringify(normalized)).not.toContain("intentionally ignored"); + }); + + test("drops all private and restricted event identity before storage", () => { + const privateRepository = { + description: "Secret roadmap", + id: "R_private_event_repo", + isPrivate: true, + nameWithOwner: "secret-org/private-event-repo", + url: "https://github.com/secret-org/private-event-repo", + }; + const normalized = normalizeGitHubContributionSlice( + contributionPayload([], { + issueContributions: { + nodes: [ + { + isRestricted: false, + issue: { + id: "I_secret_123", + repository: privateRepository, + title: "Unannounced client launch", + url: "https://github.com/secret-org/private-event-repo/issues/9", + }, + occurredAt: "2026-08-01T12:00:00Z", + }, + ], + pageInfo: { hasNextPage: false }, + }, + pullRequestContributions: emptyConnection(), + pullRequestReviewContributions: emptyConnection(), + repositoryContributions: emptyConnection(), + }), + normalizeOptions() + ); + const serialized = JSON.stringify(normalized); + + expect(normalized.publicEvents).toEqual([]); + expect(serialized).not.toContain("private-event-repo"); + expect(serialized).not.toContain("Unannounced client launch"); + expect(serialized).not.toContain("I_secret_123"); + }); + + test("canonicalizes approved domains and rejects ambiguous taxonomy", () => { + const taxonomy = parsePrivateTimelineTaxonomy( + JSON.stringify({ + "secret-org/stealth-client": { + bucket: "Applied AI", + domain: " Payments ", + }, + }) + ); + expect(taxonomy.get("secret-org/stealth-client")?.domain).toBe("payments"); + expect(() => + parsePrivateTimelineTaxonomy( + JSON.stringify({ + "secret-org/stealth-client": { + bucket: "Applied AI", + domain: "payments/internal", + }, + }) + ) + ).toThrow("timeline-taxonomy-invalid"); + expect(() => + parsePrivateTimelineTaxonomy( + JSON.stringify({ + "secret-org/stealth-client": { bucket: "Across the work" }, + }) + ) + ).toThrow("timeline-taxonomy-invalid"); + }); + + test("normalizes Unicode before private-copy checks", () => { + expect(containsPrivateIdentifier("Private work 12")).toBe(true); + expect(containsPrivateIdentifier("secret/repository")).toBe(true); + expect(containsPrivateIdentifier("A broad protected work pattern")).toBe( + false + ); + }); + + test("collapses sparse hidden buckets into one monthly generic cluster", () => { + const rows = [ + storedRow({ + bucket: "Applied AI", + day: "2026-07-02", + id: "a".repeat(64), + repoKey: "private-repo-a", + }), + storedRow({ + bucket: "Applied AI", + day: "2026-07-09", + id: "b".repeat(64), + repoKey: "private-repo-b", + }), + storedRow({ + bucket: "Infrastructure", + day: "2026-07-16", + id: "c".repeat(64), + privacyDomainKey: "domain-b", + repoKey: "private-repo-c", + }), + storedRow({ + bucket: "Infrastructure", + day: "2026-07-23", + id: "d".repeat(64), + privacyDomainKey: "domain-b", + repoKey: "private-repo-d", + }), + ]; + const result = createTimelineActivityDigest({ + coverage: "complete", + generatedAt: new Date("2026-08-12T00:00:00Z"), + rows, + windowEnd, + windowStart, + }); + + expect(result.clusters).toHaveLength(1); + expect(result.clusters[0]).toMatchObject({ + bucket: "Private product work", + visibility: "private", + }); + expect(JSON.stringify(result.clusters)).not.toContain("private-repo"); + }); + + test("caps large digests deterministically", () => { + const rows = Array.from({ length: 130 }, (_, index) => + storedRow({ + bucket: "Open source", + commitCount: 1, + day: "2026-08-01", + id: index.toString(16).padStart(64, "0"), + languageFamily: "web", + privacyDomainKey: null, + privacyPolicyVersion: null, + publicRepoName: `f0rr0/public-${index}`, + publicRepoUrl: `https://github.com/f0rr0/public-${index}`, + repoKey: `public-repo-${index}`, + visibility: "public", + }) + ); + const result = createTimelineActivityDigest({ + coverage: "complete", + generatedAt: new Date("2026-08-12T00:00:00Z"), + rows, + windowEnd, + windowStart, + }); + expect(result.clusters).toHaveLength(120); + }); + + test("uses issues as dispatches while suppressing a redundant nearby commit run", () => { + const repoKey = "c".repeat(64); + const publicRows = ["2026-07-30", "2026-08-01", "2026-08-03"].map( + (day, index) => + storedRow({ + bucket: "Open source", + commitCount: 2, + day, + id: String(index + 1).padStart(64, "0"), + languageFamily: "web", + privacyDomainKey: null, + privacyPolicyVersion: null, + publicRepoName: "upstream/public-tool", + publicRepoUrl: "https://github.com/upstream/public-tool", + repoKey, + visibility: "public", + }) + ); + const result = createTimelineActivityDigest({ + coverage: "complete", + events: [storedPublicEvent({ repoKey })], + generatedAt: new Date("2026-08-12T00:00:00Z"), + rows: publicRows, + windowEnd, + windowStart, + }); + + expect( + result.clusters.some((cluster) => cluster.kind === "issue-opened") + ).toBe(true); + expect( + result.clusters.some((cluster) => cluster.kind === "commit-run") + ).toBe(false); + }); + + test("deduplicates only the exact curated pull-request artifact", () => { + const repoKey = "9".repeat(64); + const rows = [ + storedRow({ + bucket: "Applied AI", + commitCount: 1, + day: "2026-03-16", + id: "8".repeat(64), + languageFamily: "systems", + privacyDomainKey: null, + privacyPolicyVersion: null, + publicRepoName: "f0rr0/zeroclaw", + publicRepoUrl: "https://github.com/f0rr0/zeroclaw", + repoKey, + visibility: "public", + }), + ]; + const duplicate = storedPublicEvent({ + day: "2026-03-16", + eventKind: "pull_request_opened", + id: "7".repeat(64), + publicRepoName: "f0rr0/zeroclaw", + publicRepoUrl: "https://github.com/f0rr0/zeroclaw", + publicTitle: "The raw title for PR 8", + publicUrl: "https://github.com/f0rr0/zeroclaw/pull/8", + repoKey, + }); + const distinct = storedPublicEvent({ + day: "2026-03-16", + eventKind: "pull_request_opened", + id: "6".repeat(64), + publicRepoName: "f0rr0/zeroclaw", + publicRepoUrl: "https://github.com/f0rr0/zeroclaw", + publicTitle: "A different public change", + publicUrl: "https://github.com/f0rr0/zeroclaw/pull/9", + repoKey, + }); + const result = createTimelineActivityDigest({ + coverage: "complete", + events: [duplicate, distinct], + generatedAt: new Date("2026-08-12T00:00:00Z"), + rows, + windowEnd, + windowStart, + }); + + expect( + result.clusters.filter( + (cluster) => + cluster.publicHref === "https://github.com/f0rr0/zeroclaw/pull/8" + ) + ).toHaveLength(1); + expect( + result.clusters.some( + (cluster) => + cluster.kind === "pull-request-opened" && + cluster.publicHref === "https://github.com/f0rr0/zeroclaw/pull/9" + ) + ).toBe(true); + }); + + test("keeps a meaningful commit trend beside one issue dispatch", () => { + const repoKey = "5".repeat(64); + const rows = Array.from({ length: 9 }, (_, index) => + storedRow({ + bucket: "Open source", + commitCount: 4, + day: `2026-07-${String(15 + index * 2).padStart(2, "0")}`, + id: index.toString(16).padStart(64, "4"), + languageFamily: "web", + privacyDomainKey: null, + privacyPolicyVersion: null, + publicRepoName: "upstream/public-tool", + publicRepoUrl: "https://github.com/upstream/public-tool", + repoKey, + visibility: "public", + }) + ); + const result = createTimelineActivityDigest({ + coverage: "complete", + events: [storedPublicEvent({ day: "2026-07-24", repoKey })], + generatedAt: new Date("2026-08-12T00:00:00Z"), + rows, + windowEnd, + windowStart, + }); + + expect( + result.clusters.some((cluster) => cluster.kind === "issue-opened") + ).toBe(true); + expect( + result.clusters.some((cluster) => cluster.kind === "commit-run") + ).toBe(true); + }); + + test("keeps representative monthly dispatches instead of a PR wall", () => { + const pullRequests = Array.from({ length: 8 }, (_, index) => + storedPublicEvent({ + day: `2026-07-${String(20 + index).padStart(2, "0")}`, + eventKind: "pull_request_opened", + id: index.toString(16).padStart(64, "1"), + publicTitle: `Public change ${index + 1}`, + publicUrl: `https://github.com/upstream/public-tool/pull/${index + 1}`, + }) + ); + const issue = storedPublicEvent({ + day: "2026-07-19", + id: "e".repeat(64), + }); + const repository = storedPublicEvent({ + day: "2026-07-18", + eventKind: "repository_created", + id: "d".repeat(64), + publicTitle: "public-tool", + publicUrl: "https://github.com/upstream/public-tool", + }); + const result = createTimelineActivityDigest({ + coverage: "complete", + events: [...pullRequests, issue, repository], + generatedAt: new Date("2026-08-12T00:00:00Z"), + rows: [], + windowEnd, + windowStart, + }); + const dispatches = result.clusters.filter( + (cluster) => + cluster.kind === "issue-opened" || + cluster.kind === "pull-request-opened" || + cluster.kind === "repository-created" + ); + + expect(dispatches).toHaveLength(3); + expect(dispatches.map((cluster) => cluster.kind).toSorted()).toEqual([ + "issue-opened", + "pull-request-opened", + "repository-created", + ]); + }); + + test("does not let a curated pull request erase repository creation", () => { + const repoKey = "3".repeat(64); + const rows = [ + storedRow({ + bucket: "Applied AI", + commitCount: 1, + day: "2026-03-16", + id: "2".repeat(64), + languageFamily: "systems", + privacyDomainKey: null, + privacyPolicyVersion: null, + publicRepoName: "f0rr0/zeroclaw", + publicRepoUrl: "https://github.com/f0rr0/zeroclaw", + repoKey, + visibility: "public", + }), + ]; + const result = createTimelineActivityDigest({ + coverage: "complete", + events: [ + storedPublicEvent({ + day: "2026-03-15", + eventKind: "repository_created", + id: "1".repeat(64), + publicRepoName: "f0rr0/zeroclaw", + publicRepoUrl: "https://github.com/f0rr0/zeroclaw", + publicTitle: "zeroclaw", + publicUrl: "https://github.com/f0rr0/zeroclaw", + repoKey, + }), + ], + generatedAt: new Date("2026-08-12T00:00:00Z"), + rows, + windowEnd, + windowStart, + }); + + expect( + result.clusters.some((cluster) => cluster.kind === "repository-created") + ).toBe(true); + expect( + result.clusters.some( + (cluster) => + cluster.publicHref === "https://github.com/f0rr0/zeroclaw/pull/8" + ) + ).toBe(true); + }); + + test("lets issue-only weeks establish a public consistency streak", () => { + const events = Array.from({ length: 5 }, (_, index) => + storedPublicEvent({ + day: `2026-07-${String(1 + index * 7).padStart(2, "0")}`, + id: index.toString(16).padStart(64, "0"), + publicTitle: `Public issue ${index + 1}`, + publicUrl: `https://github.com/upstream/public-tool/issues/${index + 1}`, + }) + ); + const result = createTimelineActivityDigest({ + coverage: "complete", + events, + generatedAt: new Date("2026-08-12T00:00:00Z"), + rows: [], + windowEnd, + windowStart, + }); + const streak = result.clusters.find( + (cluster) => cluster.kind === "public-streak" + ); + + expect(streak).toBeDefined(); + expect(streak.facts[0]).toContain("5 consecutive weeks"); + }); + + test("subtracts known commits and events from account-wide totals once", () => { + const result = calculateAnonymousContributionDays({ + events: [ + { day: "2026-08-01", id: "event-a" }, + { day: "2026-08-01", id: "event-a" }, + ], + rows: [ + { commitCount: 4, day: "2026-08-01" }, + { commitCount: 3, day: "2026-08-02" }, + ], + totals: [ + { contributionCount: 10, day: "2026-08-01" }, + { contributionCount: 2, day: "2026-08-02" }, + { contributionCount: 3, day: "2026-08-03" }, + ], + }); + + expect(result).toEqual([ + { contributionCount: 5, day: "2026-08-01" }, + { contributionCount: 3, day: "2026-08-03" }, + ]); + }); + + test("uses account-wide totals for one anonymous streak without duplicating the public streak", () => { + const activeDays = [ + "2026-06-01", + "2026-06-08", + "2026-06-15", + "2026-06-22", + "2026-06-29", + "2026-07-06", + ]; + const events = activeDays.slice(0, 5).map((day, index) => + storedPublicEvent({ + day, + id: index.toString(16).padStart(64, "0"), + publicTitle: `Public issue ${index + 1}`, + publicUrl: `https://github.com/upstream/public-tool/issues/${index + 1}`, + }) + ); + const result = createTimelineActivityDigest({ + anonymousTotals: activeDays.map((day) => ({ + contributionCount: 8, + day, + })), + coverage: "complete", + events, + generatedAt: new Date("2026-08-12T00:00:00Z"), + rows: [], + windowEnd, + windowStart, + }); + const streak = result.clusters.find( + (cluster) => cluster.kind === "account-wide-streak" + ); + + expect(streak).toMatchObject({ + bucket: "Across the work", + cadence: "streak", + visibility: "anonymous", + }); + expect( + result.clusters.some((cluster) => cluster.kind === "public-streak") + ).toBe(false); + expect( + result.clusters.some((cluster) => cluster.kind === "anonymous-month") + ).toBe(true); + expect(JSON.stringify(streak)).not.toContain("upstream/public-tool"); + }); +}); + +describe("timeline publication", () => { + test("materializes all public and protected copy outside the model", () => { + const plan = validateTimelinePlanAgainstDigest(candidatePlan, digest); + const protectedEntry = plan.entries.find( + (entry) => entry.visibility === "private" + ); + const publicEntry = plan.entries.find( + (entry) => entry.sourceKeys[0] === "public:source-0" + ); + + expect(plan.headline).toBe("The work, along one line."); + expect(plan.standfirst).toContain("public milestones"); + expect(protectedEntry.title).not.toContain("client"); + expect(protectedEntry.description).not.toContain("strategy"); + expect(protectedEntry.metrics).toEqual([]); + expect(publicEntry.title).toBe("project-0"); + expect(publicEntry.description).toBe( + "Several public changes landed together." + ); + }); + + test("publishes a public issue as one exact dispatch, not generated copy", () => { + const eventDigest = createTimelineActivityDigest({ + coverage: "complete", + events: [storedPublicEvent()], + generatedAt: new Date("2026-08-12T00:00:00Z"), + rows: [], + windowEnd, + windowStart, + }); + const issue = eventDigest.clusters.find( + (cluster) => cluster.kind === "issue-opened" + ); + expect(issue).toBeDefined(); + + const plan = validateTimelinePlanAgainstDigest( + { + selections: [{ importance: "brief", sourceKey: issue.key }], + windowEnd, + windowStart, + }, + eventDigest + ); + + expect(plan.entries).toHaveLength(1); + expect(plan.entries[0]).toMatchObject({ + cadence: "isolated", + description: + "Opened a public issue in upstream/public-tool; the thread remains available on GitHub.", + href: "https://github.com/upstream/public-tool/issues/42", + kind: "issue", + title: "Document the edge case", + }); + }); + + test("materializes anonymous totals without a repository or theme claim", () => { + const anonymousDigest = createTimelineActivityDigest({ + anonymousTotals: [ + "2026-06-01", + "2026-06-08", + "2026-06-15", + "2026-06-22", + "2026-06-29", + ].map((day) => ({ contributionCount: 5, day })), + coverage: "complete", + generatedAt: new Date("2026-08-12T00:00:00Z"), + rows: [], + windowEnd, + windowStart, + }); + const selections = anonymousDigest.clusters.map((cluster) => ({ + importance: cluster.maxImportance, + sourceKey: cluster.key, + })); + const plan = validateTimelinePlanAgainstDigest( + { selections, windowEnd, windowStart }, + anonymousDigest + ); + const streak = plan.entries.find((entry) => entry.cadence === "streak"); + + expect(streak).toMatchObject({ + bucket: "Across the work", + title: "A sustained account-wide cadence", + visibility: "anonymous", + }); + expect(streak.description).toContain("repository identity"); + expect(streak.description).not.toContain("private"); + expect(streak.metrics).toEqual([]); + expect( + editionMatchesTimelinePrivacyPolicy( + createTimelineEdition( + plan, + anonymousDigest, + new Date("2026-08-12T01:00:00Z") + ), + null, + null + ) + ).toBe(true); + }); + + test("rejects source reuse, inflated importance, and model-authored fields", () => { + const reused = structuredClone(candidatePlan); + reused.selections[5].sourceKey = reused.selections[0].sourceKey; + expect(() => validateTimelinePlanAgainstDigest(reused, digest)).toThrow( + "reused" + ); + + const inflated = structuredClone(candidatePlan); + inflated.selections[3].importance = "lead"; + expect(() => validateTimelinePlanAgainstDigest(inflated, digest)).toThrow( + "overstates" + ); + + const authored = { + ...structuredClone(candidatePlan), + headline: "The model wrote this", + }; + expect(() => validateTimelinePlanAgainstDigest(authored, digest)).toThrow( + "Unrecognized key" + ); + }); + + test("keeps edition keys stable across runtime timestamps", () => { + const plan = validateTimelinePlanAgainstDigest(candidatePlan, digest); + const first = createTimelineEdition( + plan, + digest, + new Date("2026-08-12T01:00:00Z") + ); + const second = createTimelineEdition( + plan, + { ...digest, generatedAt: "2026-08-12T03:00:00.000Z" }, + new Date("2026-08-12T03:00:00Z") + ); + expect(first.editionKey).toBe(second.editionKey); + }); + + test("revokes protected editions when the active policy changes", () => { + const plan = validateTimelinePlanAgainstDigest(candidatePlan, digest); + const edition = createTimelineEdition(plan, digest); + expect( + editionMatchesTimelinePrivacyPolicy(edition, "policy-a", "policy-a") + ).toBe(true); + expect( + editionMatchesTimelinePrivacyPolicy(edition, "policy-a", "policy-b") + ).toBe(false); + expect(editionMatchesTimelinePrivacyPolicy(edition, "policy-a", null)).toBe( + false + ); + }); + + test("builds a dense year-long safe fallback and filters unknown repos", () => { + const edition = createFallbackTimelineEdition( + availableActivity, + new Set(), + new Date("2026-08-12T12:00:00Z") + ); + expect(edition).not.toBeNull(); + expect(edition.entries.length).toBeGreaterThanOrEqual(6); + expect(edition.entries.some((entry) => entry.href !== undefined)).toBe( + false + ); + expect( + edition.entries.filter( + (entry) => entry.importance === "brief" || entry.importance === "pulse" + ).length + ).toBeGreaterThanOrEqual(2); + expect( + (Date.parse(edition.windowEnd) - Date.parse(edition.windowStart)) / + 86_400_000 + ).toBe(399); + }); + + test("promotes a long anonymous weekly run above monthly fallback texture", () => { + const edition = createFallbackTimelineEdition( + { + ...availableActivity, + weeks: Array.from({ length: 6 }, (_, index) => ({ + contributionCount: 4, + level: 2, + weekStart: new Date(Date.UTC(2026, 4, 3 + index * 7)) + .toISOString() + .slice(0, 10), + })), + }, + new Set(), + new Date("2026-08-12T12:00:00Z") + ); + const streak = edition?.entries.find((entry) => entry.cadence === "streak"); + + expect(streak).toMatchObject({ + bucket: "Across the work", + importance: "story", + title: "A sustained account-wide cadence", + visibility: "anonymous", + }); + expect(streak?.description).not.toContain("private"); + }); +}); + +describe("signed webhook revocation projection", () => { + test("immediately maps a public-to-private transition to its stable key", () => { + const body = JSON.stringify({ + action: "privatized", + repository: { + name: "must-not-survive", + node_id: "R_stable_node_identifier", + private: true, + }, + }); + const revocation = timelineRevocationFromWebhook( + body, + "repository", + privacyKey + ); + expect(revocation).toEqual({ + repoKeys: [ + publicTimelineRepoKey("R_stable_node_identifier"), + privateTimelineRepoKey("R_stable_node_identifier", privacyKey), + ], + withdrawAllPrivateActivity: false, + }); + expect(JSON.stringify(revocation)).not.toContain("must-not-survive"); + expect( + timelineRevocationFromWebhook( + JSON.stringify({ + repository: { + node_id: "R_stable_node_identifier", + private: false, + }, + }), + "repository", + privacyKey + ) + ).toEqual({ repoKeys: [], withdrawAllPrivateActivity: false }); + }); + + test("withdraws protected activity when an installation is suspended", () => { + expect( + timelineRevocationFromWebhook( + JSON.stringify({ action: "suspend" }), + "installation", + privacyKey + ) + ).toEqual({ repoKeys: [], withdrawAllPrivateActivity: true }); + }); +}); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..9c36ddd --- /dev/null +++ b/vercel.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "crons": [ + { + "path": "/api/cron/timeline-sync", + "schedule": "37 1 * * *" + } + ] +}