From 1365014025c43c5ba0828ab337031ab49e83f810 Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Sat, 15 Aug 2026 21:27:09 +0000 Subject: [PATCH] feat(portfolio): add GitHub timeline editorial pipeline --- .env.example | 28 + .github/workflows/validate.yml | 9 + .gitignore | 7 + README.md | 54 +- agent/agent.ts | 35 + agent/channels/eve.ts | 4 + agent/instructions.md | 41 + agent/schedules/daily-edition.ts | 7 + agent/tools/agent.ts | 3 + agent/tools/ask_question.ts | 3 + agent/tools/bash.ts | 3 + agent/tools/glob.ts | 3 + agent/tools/grep.ts | 3 + agent/tools/load_activity.ts | 13 + agent/tools/publish_timeline.ts | 39 + agent/tools/read_file.ts | 3 + agent/tools/todo.ts | 3 + agent/tools/web_fetch.ts | 3 + agent/tools/web_search.ts | 3 + agent/tools/write_file.ts | 3 + bun.lock | 294 +++- docs/timeline-agent.md | 241 ++++ drizzle.config.ts | 24 + drizzle/0000_timeline.sql | 78 + drizzle/0001_eager_stranger.sql | 48 + drizzle/0002_warm_killer_shrike.sql | 19 + drizzle/meta/0000_snapshot.json | 522 +++++++ drizzle/meta/0001_snapshot.json | 727 ++++++++++ drizzle/meta/0002_snapshot.json | 850 +++++++++++ drizzle/meta/_journal.json | 27 + mise.toml | 2 +- next.config.ts | 4 +- package.json | 24 +- scripts/sync-timeline.ts | 31 + skills-lock.json | 17 + src/app/(portfolio)/page.tsx | 229 ++- src/app/api/cron/timeline-sync/route.ts | 37 + src/app/api/github/webhook/route.ts | 139 ++ src/app/fonts/Geist-Latin.woff2 | Bin 0 -> 29400 bytes src/app/fonts/JetBrainsMono-Latin.woff2 | Bin 0 -> 21168 bytes src/app/fonts/Literata-Latin.woff2 | Bin 0 -> 38996 bytes src/app/fonts/SourceSans3-Latin.woff2 | Bin 0 -> 28740 bytes src/app/globals.css | 335 +++++ src/app/layout.tsx | 37 +- src/app/opengraph-image.tsx | 106 ++ src/components/github-timeline.tsx | 502 +++++++ src/components/site-footer.tsx | 63 + src/components/site-header.tsx | 7 +- src/components/site-mobile-menu.tsx | 3 +- src/components/site-shell.tsx | 8 +- src/content/home.ts | 136 ++ src/db/client.ts | 71 + src/db/schema.ts | 299 ++++ src/env.ts | 16 + src/lib/github-contribution-calendar.ts | 85 ++ src/lib/github-profile-core.ts | 837 +++++++++++ src/lib/github-profile.ts | 295 ++++ src/lib/request-auth.ts | 19 + src/lib/timeline-core.ts | 649 +++++++++ src/lib/timeline-editorial.ts | 1184 ++++++++++++++++ src/lib/timeline-fallback.ts | 245 ++++ src/lib/timeline-github.ts | 1717 +++++++++++++++++++++++ src/lib/timeline-privacy.ts | 656 +++++++++ src/lib/timeline-store.ts | 659 +++++++++ src/lib/timeline-webhook.ts | 79 ++ src/lib/timeline.ts | 46 + src/types/server-only.d.ts | 1 + tests/github-profile.test.mjs | 348 +++++ tests/timeline.test.mjs | 1107 +++++++++++++++ vercel.json | 9 + 70 files changed, 13017 insertions(+), 82 deletions(-) create mode 100644 .env.example create mode 100644 agent/agent.ts create mode 100644 agent/channels/eve.ts create mode 100644 agent/instructions.md create mode 100644 agent/schedules/daily-edition.ts create mode 100644 agent/tools/agent.ts create mode 100644 agent/tools/ask_question.ts create mode 100644 agent/tools/bash.ts create mode 100644 agent/tools/glob.ts create mode 100644 agent/tools/grep.ts create mode 100644 agent/tools/load_activity.ts create mode 100644 agent/tools/publish_timeline.ts create mode 100644 agent/tools/read_file.ts create mode 100644 agent/tools/todo.ts create mode 100644 agent/tools/web_fetch.ts create mode 100644 agent/tools/web_search.ts create mode 100644 agent/tools/write_file.ts create mode 100644 docs/timeline-agent.md create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_timeline.sql create mode 100644 drizzle/0001_eager_stranger.sql create mode 100644 drizzle/0002_warm_killer_shrike.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 scripts/sync-timeline.ts create mode 100644 skills-lock.json create mode 100644 src/app/api/cron/timeline-sync/route.ts create mode 100644 src/app/api/github/webhook/route.ts create mode 100644 src/app/fonts/Geist-Latin.woff2 create mode 100644 src/app/fonts/JetBrainsMono-Latin.woff2 create mode 100644 src/app/fonts/Literata-Latin.woff2 create mode 100644 src/app/fonts/SourceSans3-Latin.woff2 create mode 100644 src/app/opengraph-image.tsx create mode 100644 src/components/github-timeline.tsx create mode 100644 src/components/site-footer.tsx create mode 100644 src/content/home.ts create mode 100644 src/db/client.ts create mode 100644 src/db/schema.ts create mode 100644 src/lib/github-contribution-calendar.ts create mode 100644 src/lib/github-profile-core.ts create mode 100644 src/lib/github-profile.ts create mode 100644 src/lib/request-auth.ts create mode 100644 src/lib/timeline-core.ts create mode 100644 src/lib/timeline-editorial.ts create mode 100644 src/lib/timeline-fallback.ts create mode 100644 src/lib/timeline-github.ts create mode 100644 src/lib/timeline-privacy.ts create mode 100644 src/lib/timeline-store.ts create mode 100644 src/lib/timeline-webhook.ts create mode 100644 src/lib/timeline.ts create mode 100644 src/types/server-only.d.ts create mode 100644 tests/github-profile.test.mjs create mode 100644 tests/timeline.test.mjs create mode 100644 vercel.json 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 0000000000000000000000000000000000000000..991445d78ae619a7378ff219385588c7811c88ab GIT binary patch literal 29400 zcmV)2K+L~)Pew8T0RR910CLy>6aWAK0RadA0CHjg0RR9100000000000000000000 z0000Qh6NjepB@~COg~6gK~jfXKTTFaQalD=KT}jeRDn1Eg%B@(5eN$QXx<|WghBu? zfv9K!HUcCAi2wv31&A64i%ARz8zS))Y*%BTp1|g%HJ8sbD%h@O+zv$4`n_s4!iJ3l zAl@7k{eMzY8AB7;lNO*l-R=*yWFwRsIf8~9GL0FjwPHg^@2WWFJlFf012?ihsP#qZ z`;kyPGs$e>Hzyupcl@oUYGa#9SVz!vuHm}znb*V>SP;aAF)%QlYFJI3FdKLK;o9Vm zL%sj3B9mWq;uIuOmskyHi7k;>(vv|E#`_|7Mc32Pk68q`P5g+29AAWr)~F(~disD- z5k1a6Is!+ekc4EGfX3YEm(Rih&k@b}KYJIwgd~IzLI~mT03n#AG?)b~MN(7(MZA

6yP-h*_UmWm>FB3IQU-1<}Iu{JlGWR<|)Z;EicO6DLr(A24;o z|0kXHy+I8KZ5c3%1D~?{4;Z`axhXP|kBj!oYnsoT1z>N$abyxS0Js^?qnz4|p)g~W zFi>Jxa3m)(Pys-F-N{7^VhI2eotQ_fAzGgel1Xjype6J*v<*o;?cWPxwlc#qI>O`v zgQ$Q!;@VF6eLAaOUEQLV*fQ;S$S||JK1BOn5M3B{Nty$K2q;6~__F)ooPK{LOOE3} zfj~?3(+L@Y9 z3zQ-Wp?~D|Ac92lwycGr-1ZCLpMY3vCY{t7N+DL31CN1_-%Joanp-FhGO^FhP`~~B zuVpFicmI0BD2~#ul&7SYc9cT#6_S_^5Je;?b~C-Bxxam*ko!`eljN8$ug;l;NXN0=^j2RkmXrBN76Bkk-Mly*c9;2}Myf({r{APW?3Lq8@0KYy(h4zJD*em<^1lqnS~i>l zFI28wx{HflfY@CS(gGy!7OS)XdFO@V2SuMiQt}btMN*!TLrio^A0U16Nu8`^I3J{Q z2%%^~$eoMQO`ogUMWu03d9Elsmyw>woA|)2cboLBzyi!-r{Et;u~)_AQ}=L53m{f=Ce83*GPL_fsnx zGP*{x4+$y~WeEhSB_{RV*S!O?Vk)fzExU7}3*h6`-wWXi080U%0M25g2$^?|=TyZ$J4i&9|nVX;K^$AC&Hto|M1y%e%imq%G?y z{Tlsx{SN&;W6oGKmW^vnH%^*qbKZ*Eh#j}*>;>m0=T_%-=Wgd7=Lr|t5BFF1lzscb z>VAB`xL>N+$2%3d60D>ueU<(ycGx={9Bx&Gs<*0EwHh{gbS+SmYf3GBU^{x;LAd^9 zYubuioTD^I!?d5S*QGv`VW3|NvDW22GK+lDzQ5UCpftNVL1r}K0$RfOgJdA}TBbY|W zQ_vXc(k0C(Wfs$X>`(mEpZS?j#mz)3m<3rNgpaQfd&x#pPnt

4xJl4d-DN79mrX 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 T=ND-DucNQ`tADQ-AuI&|D5jKI literal 0 HcmV?d00001 diff --git a/src/app/fonts/Literata-Latin.woff2 b/src/app/fonts/Literata-Latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..be435da738b21e5b11fd7b33c9f9a78db098c6cd GIT binary patch literal 38996 zcmY(pV~}RS(lz?DZQHhO+qP}n=Cp0w#|i3CpiXGqXh8uF60!Q4o<PL;rBg`(%%{5qCUP5a)cS+hzDVzMfz z+Qz=(b%gNQ_AbB?D3eKuMto+-rX)=%j;a~Q*<0A{K&^i$9+6zP%b-)rT&ZK962e^_ z+D4lR!W)_0X+cajD;Kq_vC6Lw6Doq*WODr1>s$Y$b+&ibY@uwfx&SE|wuXV2;5;G+ zDCLaPB6K>|$@p?5%4KFalMU|7WuK1(?qrJdpw>tn+kC1=<)Y^y&tr5rD~}^R-0LLQ zX(k7FBVl(7!=@}U9UwK_WKGt&bh> z=C>MN85!E>9!(Hf;+gNCs!q3=Hs+yG6BBAXnlD)^{`cI%P1i}0=qpzl1}28@E}2CP zQKD}u;+Ke<*}fanK4tonNVWR4V|zeZHN*z31=q*jwT!Ru)S2aZ)9?sE7p=mZMv3rK z5)2ep&SZ{P;qc70osVEKvTzdo*-AhSfBMht$V7FC%5#b3j1FD@hBu+0DKpAL6c)BE zRzauRa3q**jYBSN?a4BXO4VHomN$*5bvZAG6{d6;b`R)4AKeQBCdzZ$M}kCHWt3N#5S>&0RV` zOf;zv@$^fln%e30j@p12HXC(-hkToryA{5008-qNV2v+RkeD~KqEHZBnC0XUI&R*9lDqh&Ur z(n>iC5TuTj#BK^kF>MRIRQ3v_ykkDsA!R+9$nJEQm?Yl)Rqy zJ~u94=;23J=FFOY9{Z|2H+~8m+9HdAIV*kT(2O39OPZU4UxJa+;_D}0TkNU!UNsAM zy$mp30*La<@5}4!J9JlW->j>ut3+*6;ny?RJt|J(<5G2yZ=n$HaYV{&wgkc*Wd{Jz z6}!?lsT~`*P_o5HD!1DcTI_#cQK_4}OTrKKvyeRWb=eU?dLRy9YD5;$kL8X2yct~Q zmUIbtBC_HQVcOx=A2E+wVbYWF8bh&-Uo?IDzID@W`#$XdlDq5_8r z5+ZUULb~qIe!d~ru6nn3<>&5B9xP4C2%`(bh9H^{p%aDD!Zcon^}thoVTDNU%Qgkc zbXlEATnr@{O(+O_Iy~$aH=ODWI7SJL??tL!z(c572R)ek_9`0Ht>}Y8;w3sVz{Z1v zl8^2oU6vBXo06f9+x$hd-i)SG(`w);8Or8MQZJbnr`dqBTx`O!DN_T$(`uS8Dg$P1`0PtWz0)Y8$ zV&{bn)f!QV1#^tS=dIc9*_AGzw|nrFDDDiUJm*q4V+7 zhz|N4KKdB)gGAa!=5l1d38WiqsAmtf0Y9pp?OXRLwbXA4tU76Fs+9~X(mk>aXnKiY zx5wmb-}UWpsvR-M`_Hm$BVvJmWorf*UG*PX#!gK2hAo*^?-j%Y2IhLdbSc%U2>@A& z;u1njFeSTJH59#)wP*faW%|4U3z)CuM?-5cl~pI~}#J)1~fH6`J z7~<%Im^Z-6J3C8s6^<|MtV#w>qEzs0UK3&sw)ojM%iVV$alR*S8OrF5(ZkDu)`etfAJWP{9`6}AlpSj#kD(*A< zPmHGvUyaZBorw02@BUh>elMSno!hlj-OGIYzhI-Vj22qN#2NbD)@>*q;N~mzGIb-E zzbfl);@lFD*-eAe~}@85YkyhKQ*FY!Fy ztTrrTX#rJij^+9ciU@tZ$${6Hp8SDj8hTS7IDPOdoA*D-PM9?P7ROQHS7Bb{`kuYi zpYjJkzWnq9p4z=H4xV22-BIi|_|&)zc9G&=HadQY>Fo2HNg%u6ini-dSmwk?UmRME z;{I(p9s4S<2)(eymTx`(L}*b`fHi?&%PRJEgE5*ji;LxB5zDSe;J|c85CEHBH5>0rp}K_CfLIV+m_{9vDN<=(>-~f?-Y)MCGYVe_ z7-uWct0shW;<(&;f-P(|oLj3q?edEeX_BY|a5@QZC6J*38GPnfH{Rz%LZSkI==zO7 zN}{eML8ewh(3u3ox>PC47&_iZ`zo5gkxw8s-)PK0Z1m}!MH}eph!AeDP#<<*> zOms*S>u!}*f5$jy!DJeSA$YA1>KQ~z$)vl zcT|$<2``rFi9AIgJV{In5^5YrxB_JzLO2MF9cux6PvpM+iWe6~!1ER|V+TS?b*3P&BX8(lOU9ErkGB^WhsJ7j2y3=;!P=;G>KAXP_Oh<}08rOO9@()HJ<2U8EI z>d;vYu=sY*;e4hIbw61TUbxoj|L9^UNx>~mouT-Pj!rlud<9*X;P9uXao1H>pM*?G zLr-XDYuM0SNkZd1=7{>6x3`U<?)u>2#YbW>_(2-X6{`_W4x!Y0~saHme)VJV`v^2N}yczM__ zAZ1tGKWS2s8rH34Ys@^V&xz5ahlK9j+9sJEC84ovYhhb2AyhMN+NG6}>cboqq0SlH zj@dUK^sQ$7m|+I~5D6$FeZ*Ui@MZs`3%KoH2pmDC(4JIL5pLeMTd*RYkZ3MSxHxN* zYCbr3kHzv+`Xd$Yg8q(f+~f`Ec1t+0Hmd>lG^v-Akq=;y%S(1wOIAB`R}spv0fO-PtpbF6grmhAM%DW(rurQ6Zkdn|ShP_93zh>PEjG)cp zWE!3Oa@W%s=s5DC<$}i_$MA_gx|J%&;HB*I?8~N?>G3++{}#qTYOUd;QQM0=3#m=C6dhXUZ^y!Bjh{M+gGDJo0!aWaf0M= z%P7q6x&ioN^X=9U!gE=(d*`#3QMcQH;Y&6_0yn_lD{s&$_I(zQAMp?=p~xc4TIioJ zm(2sn))CG@3>kes8=E1D9%Ao+4!^Z3O*anWB^55+DmMogtHdP0rxyQa-ciR)SpMcJ z@K^~86I?tYDD4oWG`UI85f+<~rRtzG*upL9nW@OL91)hO>xF-Vq3QV3Nm%&l0sA>j zUe$VOP0#>8>L>e!hPd)Dl+vU-g*ET}nefDvTiv>e>?6TsUj_|f%sRy?Gh34NWV1mR zf0Fc&d4WGznW|rK#j4&_jr&9oMej-yq3V$wjQNq5a)oB8s#dN=DWWpA*+m@lBtAp; z&GWNoH+! z;)g2m_xeNEY0)2WuK@t`icsrT2=a6piXf|&^x{rH3l$U$LRl_Sa)ZOV#6=IEvp3z~ zN??j*p(7>>|DmTK#b5{yu8WRhl%CBX6(?g6nQ%=}&6I3XT_-WVIf_b|C6pIxn8aE8 z3++WDZIs3wuRf!SCac0FSD_qIcJ0gxhkeIp_4mGZ1Bv^U=-p6QKn0G`I1x%jmV+73 zWQnwi$~-IY7!uMrOXb@2H!w`iW+N?k#7Pf~D1A{6lUpOdSgFCt<>AVn=S5A!z};Yt zW7tSt(=Z!)w?A7Hj(w8QQ5<+j5;?H?7)oKJ6>5wq6CQ+@obcfp;!i_HPN?c(2BJb^ zNnF>%E-4}y^_sL2ES99889OryyCMF48DJp4L7-97*s6ao(=i`8x{c>_FPOFqZ{px=;}547d)J9^W}bLk<#@62BP zRA^1dQ=pw2^~L|NpIMvU-IKB6sXOcTU>@IEq1Nk;_J*txWoqCp4eUvdQ$`knz+TqU zW}7j2$?r!2Ln#T1$%)8FFn9@&aoEH!*^Vn&;hZs>)@gB7tz70L=5*QE!;^S59q!gc zfhjpzLdPf{a&~rjq0;=V1utO2J6$T};X6G#;4nO#pSPT5GT#dQBm3)28`vBy050(Z zI+3aBj%*4#goLP=5SD34ZxV{w(CjZ73u*0vap%*1n$TIMK|^3X)hJWYA7zkU&7GAQ=IJSG z9QwVB8o~+}yMX2_Y&Xs&4CH@b81YXs%lffO7u=jxd)$r(`HGT1C6|(CXW7xZ4PhCm zX`Kk#@C+Qx){-VqH=9rzB{E+TG z9HM3p>`S^%K-asyEz^|lO_>+p)>-{V&fyJ?9}V=Z4tls--aslS^?%@b%)K<}Ti;aN z;xw z+bdj71oq3P0k+4$sV$TJ;Gw8mkt-03VQBaZs1r+>b-=Uzt*I+#~$MgX7D?&x&NvQ93sQoKIKG1lebp!;?szjq(I6t56eX0ueA z{m>rc)-%a6CBaoqvBx2595U%bbHc1e)&a?K>C6KvL)$84>w|v=Qh8zXf-O zX2wGa5v-q%&`U&eA~nWWkBJ#7O2{5^A9ggMU@1s=)?2^KZ!C^ePZkTIm_xdVA|e_f zvXgh}4uA*Daux+FGk3`v(Db6=I=6G=vxXffj%JGM_V)F4s~hO3#nd1j8r0`k)m8Os ztFdXUQ|pmcJEp3EEhGRQr0Y|^H7VVNnTR+6o;jTPvOz^tIavwF%kq1H8S4qK1`v48 z-hMAcvmMhh7I+WN@d4;Z{R$nId=_lQ79y>f@E`t2iGHD%C6bh+isE4Zhvt{RwERoP z60b^TiCxKLVyF3$e(Us1sy})8I@QZ}OYxD_Jr{=P6F+PCujc>Y-PrGLsvM<#Q4pRS zlP%GRypzJdlLqpJo|*M z`UnyHBVxgGKrSnvQbC~~rFy^P^Ai8#U~Xh=Ze(U6aIg9d9aa^YOk@97sU}n- zLjJ!db0ija4UPA5i66uoZG}pyWX@nP(!73i5&Dd4n?vXb3-;w;oWq#(Or@g?gO-VrGm7v7#u!_5T!!BMxI&A;8l|zIW#PQ069VNCp0*0)6ZJ z&L#OwGH}46`UB=Ts3*t@dkqfRQJw*CWZ3hY@(Xm0{UAy<_abyV!Ouz00C^%!ta*S9 z6uaTSv#k0Aj*f6XfKC9$`O%yv^yW?airNDBP!n@6vxrH8BECQe(Zvrv69W`2MtPMS zG-DbZ^hzQ6klMeX&fFrrY*`N;nu74Eh#Y{2^WQT*(pQ+<_h3x-^PnmGC3Tl$HAQz~ zok=mUXN&cHk7YmeHTXaWgfd?pe#t6i(xabo4Tyz4R^xtQH}|6Ylj%8ZcYI&V z28&ecua!Tr4h#0pm1)r;U-Ac#k3#8b@6PZWnJV-x>A= z1qeknw?_Cl^U;kIdZ126f@wrYW3Q$VdU5Be3HTcnbvaA=PpykJi4RdEb`E#Zhb`7k z3sAX5SfseNkDMC(g%CRflFyp>1EbEgOa2&aqeVCo&K*hRr0lv~{xW9wgHS&mo+i29 zdBji5tOl4}a+ldNK?KmEA;!&TR6NhDh?ngZC#nn!wdU3Is9klHbst?ogVSwNF!Hy< z;DCUTQ~x^9FL)kYJlq&Cj0d|r5LrHoGMXZyhHy9AHAfuA5^K{N%{zZm<~({iH4w1H zQY77mbnYFcNn4+yd@Y}hRJLd$U1YZ+j_rY~Au{wSJRuR<;ETUfbQzG9D3Kqh#k!dTEhkGmW?&J!nt3uvTk%&!wMh{X)uoCM=yBHm2~u zcg-skoV3aVUH|$=IH9|&l>R+_`ln^WPUk1J3UaU z59+`Pns7@}bh%#4PmVbl@*um}#D<%~{^Ps8tl#KcNTAjLEnGbWbF3!yk)Hs;o(RD* zH{vnxv-Di86WtDB@1UF|i`zQ2HKpG_yHX9kI!zdH zd@l?f12iP*D8NW@TVve0{si!P|uWE@kWP?bF z;1Bam;HCpk{B~LU8j8w3gJ)A+qc%Y=brhY)o^)j7ehQf71vo|rtIWM1qFm@_r;pL@ zm4Ho70_*Oa0GF`QOSQu;vwX`}B7#GZ^4?uXvGY zkwcv1j9Sc&VmYcX@CEm7D@2sNWpWp}#3Rn0{Yg>i3`r(G7anv;nJYJi3k^Ka;#F;( zmD$T(YFPkdhC8ZS?v)($_9563n?*gGRCyXDDyZedn6dGnUQ9nmbwC`!-;Bi-k=JuNZ_={(S|OQdRo>UBIFv% zO2Z9fsxDpAJEsK2`>nx+8BIW(&a{QQVpDrB&M@!kv4&4G+RSqzjpsFuc-DOCMwu=C z7cBJgxDoXHs68i;_|o+M{!`O**w&GYVK=_1H=YR$JMT|l7`1h8s`HS%7NI`6&9n9G z_E+RDpN-JCgKd7LZ6&%YeFH(+5gZ3efH`iEEC|n(Ez!#&Vw&Yu^hNZ0YOmr<-yo=r znJT!66y_3##w=a@`5Wi%Wln_mulkY+a!8{QZ6fBis{2o@I}-V{U4&qis2OPl6l*DSfr$ z3Rd~WTE~&}h;v$EjN?Nh{QNh~AN(4#CN_38+})&W!d~P^9D|-foQ`{nak!Jm>o78@ zL?s#i6piSZcl_?=cZt27wi#9l)q8iIk%4;yrCG08sr#mQRq1e%!!|QJnC2X&N?(%Z z86&!?22{bo$6B-sSDGB!M9=w5Ie5eHscq7$mS`=7cz&2Ag=O7VX%od{J;7?4VQTKK zc@yfCBCnk9h4A;|g;k8S;V{${HPi{z4VZ{^1_n@q&bB=%v_1?~Ku9O}V3B!hT%iqU z>u1Hv&_R{NZ!@^o6fz#lfEr&E1*6U9(mus4nD5CXauQ*-U%l3-unMZH1p6?0e3C$? z*FJ~G7k>BKqDuF3vRdablTd&4l8kvIsu@70?Q;)g_XHr?eIO69f=b^;#bk} zo?MfNZ5COa_vo=5-oHXdL2*6`#2pnjVqiBm(lCiH4Iq5=qXvw+^0v@WDXL4XEv}Ca z%1tpI0l|Kz*BQU)MUJa!5xIWH>Uy07pYL=h{31R&+&}&o!P}{Y627`>)fUJ%7L`)u zUC&mE(-ZpN1;NJ)mL+xP=-tcit6{*9^H9&2njRM2HdFXR*p=Y{Q7ynuR-n# z6IN)jj;#IbGA~-eytTfA04ttiuy6lt>CLJQYpN^)c07~n5oWEgP7)pXcZTbJaN&8E+v1VqjjrmvlmXDpiL zGQa#QxsnZA=iUd)+BsQfGXo^2kelom$D10t*s`(kcw|zj$@Ic~+EH;BLi}47iX%d& z=Ae*ZG280+EVd&2-EFOeZwHtWomb&a?EnQ_eFoZTx$1|2(ejK0L`T}{#;on<{bMth zK3cKct7T#hBGNDO%tc$e!z4?gI?kF>1l`$6J=dW~znvV});MTu2V8(Jfpk{3@-oA7y(I;w$?1Qp7|6Z~WFxnh0~o%N*f<(lVu`8i6vERLnIUCGpzw zx&Nh*5(+=ITAd>p&ddjOTU&cSM`lrz_binmUu(A6Le_n(qlR7Qt3=vx{^91OPlNP8 z4PA?pfl9}zR*AGJNNg{U$z)Cd6hWPK;+b|^2Ln4XeH81cIw&4S-AE|JY3JT(C-53v z$3#JJFwx&QbJec(4xDr`(0N_E%ecvsBb*@gD$vS&j;T@bAPXsvqO{5NF=D4X&)8#=Sr=TV_K*s|9$B)aXX?|Qr z1#=8j1Ly1)u!1%kL~eIF8e8^9@xC2Vj=!~>K&o*+HT$TJ6k`~ZlROPn<1&o6oH}er z#qm_+=d;m0LIW`)z#%+?0qUA$0BOu1Utm+A`>&&Xnzw6D|F-}XHMUCHCnLVW3-gSl)F*^@l1rlWDixR%ofV!I?=Qf` zqr|}GbDr4t?>;fV$EgXYeZUNh$_z`5>-A}-SE;y&hz|WKoVu9YurS)fmlM zKJgk`tMX*ertK{ehu>c|bHj$c-lesmnq|(5EU%i2DYan!Cy^$FxQPna@q6}kndJzD8pk6s;EWzd1B$It@=e;^A6x4T7B)UK zHrS8~>$v9k0n;oo9?1x3!jm_G=hl%{<(PoX|1EN;ka|pXPW=Iop%YFOBHYMSLUinhPE!Z+3O_KZu^n7@ScnMi;T0Oo3zK#;XMcOeUxf38Slxr< z;zh-J<*!@-+=s7akDN=w&;AFA462zOxWx)usgl^i%G8luX;rvy<#_)w%QFQ!aktRK zP@#uxzodZ_^1r2ouM?FfvJqt#spNkcn3-udJ*+-C18nT;J`R=MGL;e0BrX}9-TmE6 zMCEgqJD`#?t18ZHfzz~*bkkff;(qiY4OhU7vZK_f?0P-<@NV7Z!cetGq-^B6a6-mRMS?i2aBLdvni}|p^@hPi>Iwk}&rnNSglr^s!j6x| zGXu{{XI~M<1BLLDB9)7%L~12YSpZ6Ed_PYGr-Q*ov|i(jfhhtiDh_#D&QFx+>iQ5) zpj?Olf^f2YSl{e?0Vdj#4cv^8Uh!`7Z!%(C#6LO$N#)d{=((o|l16W@kWvF1Y1LW3 z+%_1C{$fQP`{EyB08x?T=_)A8Z|BKFD>azK#Qv1SyG(<|Osm>wjY@{h+`osmKV6A3td5+Z&&!Z?JoNI(5z0-(5@$04nCZ+TXd$C8+%o zgAZg{bnqDk+Hv8VF~d~+ktF0WK_64m#}xuT%~r+-ykswuP)Ns_zAW_$Dx?z#ACVaL zigVk!db!^cJ6g%wi15P;dN!`|2)^^zlTaNYNEXzPB6-h`m;iwSXLv92#CrnW-}v-u zNE%dllhHC;MTz|`n2QAZQ_OHJti2}GT@@u&Z(YEkrzT+|1@{KThy=&%OsZZH!a^zF z0<3EO+4;8GflymE!`%#LC6My>bdICck4w}mdG9RalOER!NHWQ!Js0pRrQ%fuS3q6a zTlsO&F*bUn9cLlURn^DW$Js~QN8QKEN6$y%qtNAW$s^B0_gxMr7Yk65boAb?uas98 z4ygGZIh}jecon5!ujO;a(5S|Zl=#(T0f5EFiyx#{OrQtZJ1VcW_!@V>26*_s=NM=q zbS=Fn{mJZS3b0Etki+(W{C&3q7#=f@^U)ZF@D1D7YaIUh`b?eVKYymk_1O;yL>3Mw%SL2%mHnl;w(l4jDK$P$MYeIl9g@+<9|{KA)xSs3HRnNc-t_6r(=2qAQ#b2opxkqZEz zWZk_0Kn*lw2#=SaqVkGSS+K^>TdKlWk;+Q@rQBY$i?cnO!Y`C1@CC&j#XN7CZjq~v zQ$U6rCWq-mzbP8sjPUiN`Lr--m9zq%lbvj$VHSApigohDzl+SV z*G7$(1ehk`(~zul#jDd!$g9?V$$B*rQ$dM|U^_`Autu6bb|6<>w;ZaI`cD0^$m7@m z^Bpian8mvI<=%!5uawjy-e}u~-Ljp6n?YK*C_`7QDjp9TVo#8n9Y=1t3FRn14Qq!&mSQD00=%*83z04S^<=#v zE`>Jj<;~Yp>*`~NOvVJc-}C^n+!j;xmBgvFN<}Wc&v`A+zT0=Sd8z|?;X4;!NHUPU zaw~mm-Uh_x%W%iY%m%eHbpxPqFN%AicZ|wl$NHr6`$3=D!67M5FaMiZPs73O9pK2&B%U>U>6NBT8u~ zitq)GUym2^%kKIIs?!w!64fp@4dkavF~p3D7v*wrJUx4cxKL$ndNv5JktQhKNvG@t zE2yEePRj}lE`EQ62g6sX(==sM__^`;VLnfPI^H4w22{e!7KY#9|F~oP8oj*S^6oeM zP7eIpos?CUoFj9LYFiJFlym|Kp`owX6sdZIE zD#LwZROX~k!AUmSr~#ig@MOK0xtp z#7?^_!;-Vqh}DjEWm0N3^gdKM=IuLy;@uhC?!3zh9i1(a51(|T^*GnZyrMz1UaG~P zg669Ji)hBsxo6MJYZI@s@1${+Z~G3-U1nlBE7ZLO_lflPcWtU`;RZs}U(DW0d~W@K z7_iV!;mhstZX}-@b8q#bMT>%S9$!s~QV7M~PuACM9}2+!2n3sF$w}kvzlsF!Ufz`f z>N9=sWB?6*X)1oPwU~lFNfi~<@N^xqK5+CghP}1V_>7624Cl4uANiqsEyalz3%Gj5 zrCOdpSS`_M&BuMN6T|E1?Rd|*v`TUJY67=-A|!vxbjOtN%oRO{Tn>n57&)Xv_WKfz z1}0(Y<>%YCMe#*;Yyz@ve!mo&W(d0G6F+$+Ymr@cp?zuC0)!$mK(_nIQpy(Gf^Owq zs_BjtMF*K9?CIJ&>Q_7-fMh#=3wii6UGyxR4i(FDRXA7fhItACK_-iyjl@Xff^7cL zq@FYHhdb}Kw@jESEnnVsD}x3?i2!&k{?(m*~9LApBh4K2PF&Cytu!cBY3In@k=Z$CNqarYWF?^ffTt{ zG+WpTwIzC^2cN|p(lL408GU5L`uBkC#+@&i;FifbBl0mDo36(JYp6H5I7=PG?PEQTGSiIger|FW^9lzZ2l1oHkYS_H55KP8tD1 zl{@dlMye!!R!(m4WDV=Emqq{Ag~mZz0_(gm2@DXu053)vGu?A*yz9h^?&MTy#dRN~ zI&>rPyk)VBB6MJKYGU@9>&lB}k)>A;KG7TiU;KQvHOA~V#~8xvI3ZU1oePhgB`Xbg zvLSEYtp=mAR3gG#=Z3H_-mQlhtZPxKi_9@qklrxV^FndR5Sy*c=sl4P18TI4EUUTmH(_39<;MmeY80 zDsHg&%MRvBL`{<6M5)n!<57x-RJ*cu)UwmtIsg@KYSU(`vz6Ama=l>}6`}Pc8i0Yl zd~q`NfN{LVK*%S~1ph2J-zsgTB#=)K!a5z(y0Pw>eNB7C3WXCJ@rldvfjfA~M>uSP z5NotX8jRV=D$MWquZ#aYD$1I0bK84ZBvwsKFJ^Ygke3`Wn=K~a;)ydSdxpW zQkf73Rbo=((^zn=ILnIm(`cK0V;%WMp;6C0SA3iAC#(b~5C_xTbPRbc@*NZ564taU4!M+e4ZV52eF& z{WtTCUghkrI3^>B&8`+(%y{vkNn38`cIL3}aOVUHvW+o7D`ejf0@>R}Vzg3d>>!oV z>D2!Ys{E&CuHYv_aZ6jvbM5o6^zIeT&BnXM)4!F~q6_ip zm4;a`b8fV6r)B?gB@b2GPyOU|+6cekE|KVi=__CZi%P&N$DM~XKSROxb_0z4yL}}+}49!E{^(3n2}i}pms_vu6YEmsY(@& zbzv6?*woPreKtS?o?ZyLOOV9WP*=*D2@DYB#yAAb=R7^Qbv^nSu#aSAT$lr_531gf06mfz-Wa7T4Is zVB?du!G}bgm>s;pY(YaTcU(EIX;*(K^uSFM;5p}^uU5l%z%VMgLfdG;y;{Z5^93G! zZ{R^yGneHGedzC@JKsT^^e31W$?G^jtF$jG`oKcWea-#ie zOM&Zhh;ALG*pkcfgfr>z=aa}`5t9vSYM}ju;{f>!)lE12h=Pp*CnPgwDfv6@JulXT zc_&UBQDe(np*^YCbD zO~|;yB#-d-6~FO~00N%EZDnL$v0ng(#rKK_@1t+DBEMdKAy$#wa@}-}3ti#k9yI$Oi*Ne|{Z zJh{GvMwCYZq76G;R54n=aQfiLDsnP5AeIT6vV_xo8_UO37QJ^rS7A~{rIdT%`0q|C zJmP~Fc$VcVpiy%5?NHl&Bxtiqwcg|0L)0qQMXb>bFo-L)7rvtQKmo_NT=-+>x?Q zq?w~++0AW$$Gdj}h(#{mJ{he$Ia31;9p`4pydSNSo?Azg=SBEHU7l?1-xqb<)N|n8 zaODa7i~~l90q%N|x?V5QQIYq(42SJB{1rb2l8_&#oQF(_3%X$CZ3E^2L1M+o_d(feUAqnq1!=TW&(|#^=3Q zo%K$<4kx1v08OC43s!yI8rJ6zvZCWDKkSpyPm~RXpOJ5i>RKZ3%Y#3F0N6+A=bZxO z265@a4S(Ol-+qqfz@MFWosw|kP}Pk4?@2j3tl}w}9$F#w^d7?8k|#5!&Xf9~_bO#Y ze%j9Z@a}^Og!NXb=!qK#X6A`)&^?#1RY)QilEtz&Y|8@;&T3s(;5)P9!F^W@2_pK> zZClQ75Ie05X0JsEE28M&%a7Cr8QX$1`YcYm{Zc84?crUX>Y}M^WqtOZK~TwluFcA$ z@v5FML3tn*J4Q?n3FiiI4{Gnl-sdMBRk;p|Jz%|F16!`Ffu><90cayk^{lMp3@OXS z#Lj5&;YcWseC^#8Q9Lr3=_+69@T1hPL)ElMB=)6K*Dc&LQlIoI zaK+JcjjBykVUz~`x7lN-nL~Ow4$!y!XtU>qNe~fc6i|}u9&xMg@24p)^7U_%-dJBR zJH7Ja&LtmxvNE?nUj%Syh+Y+y)l}|fnTFtl$vXvK9@~!SdMfn{9Uebx>m$BgT!_E>0bu14W+=V-wI{cO7TW#w?guU_kr! zpr08m2e9D$0}^bh9!THbAA+m7ptm#MTuQ5=ESSUz_1jX`#lHo43n_nn`5e7yYU{8@ z*v-kb3pY`9F8_f#o`OCTwC%|zVjVA~(M8QIg6>^`lagznACnFRO6$nL>@OoP$l-ca zZS(okOgD|EsMpM_c%H*=9q)8VMlQ;!v~4vjkmBdr9mYhvd4yWt7u~v(TP3E*?2xi7 zFpNVp(DT)g9o`x=%D$a5u^WlwKbkj1Vtqsuf zCAb%_9tL=RzE{)c`zj#j6CbYEmgnbBdm>Fg7e?*_;omeqFZ}iJ_%cSIc;|f0PF8rz z#e3AR@y+T5^hq~^P6k3v2}Q8zRZEn`RP%w2P-kyQ^e+*o_e{@^bm-#7ZHz3JB#x42 zRumiO&<6!Sk=A(Ndg{(o!KANe;0&w|u7tfzIW|{hWW`7t$o5wuz@-+V1Lxp8bWouCv6y5JBW|O?yqEN`H=rE)+JP9MTvmQv?#>bXl%hTxGx65<1~^ zF6~{nNFGbThnF!(DsU_0U-P-$>f*^IR^eN@f%EBZufpf`+w>$zbl=LFDNnpQllKR6Ej=Vqj-L;!op%2JP7xqVJaDAdx8(Q#dP=^}A&a8kHSwMJ-Zyf}Az4PKnKbf3s_ zWU!P8ZZ6-c`+^&agLzgKAvHMB$0W(^i2)`F?5fzVPsi3X*$055OuC-9O<`~+*+RP= zkZX3y!d@mlVq5FR)IiilBE1pA&h{}Q6&~{Je}65N$}NzmMiB-TMiV$5t3(lL;jtK} zhv-a}eC?I29ft5g6N{B6u#!0unM0^T^xN;Ri9rZml3YHRkOWjPo9kFF+d5q90=F50 z2k}mDblzCW7-zO-W}(p`cHsINk>YFgT%&=MK0+XRPS;DX4>b?~BEsfNh%R<{;--!^ z*_AHFdk*H$6Abws4u1Hjq?E=x&diL>^Nx0K4PZ6ucYy*3T8ob_DLzj|Ez&&Ujo_@$ zC>vuFvg`ezNW;Q>SAHS%4YBfZPG4-74`zcF=dE!83d7v7oH#@l~&sOJ(zU`)|>WpLX zh(WHp)Hq3WHhregOPj6F9Vq`G-c8NcVZp-khX#(2`-YuvjEt~HW&&h{-JcLM$*Ipd zXx$=-Q;i)oBAUocw~FN4MA{(+wMcb5*8cm(i51+T3_Bpm6aON*BAuavD1z^!nI;t7 z5mLy3Btka|-u!0K?J(*{f;4wQ{DKVpNro3H1OC2>_J%iHrV;rSe*>FgMA9x_fKg$N z)F_;}qIklcozOonCgxEP5{JSgbfXr}yHvp8)76nFS;BZ+UmN;fSlEp)SUwi2}NQrhq%Xtd9=ErW&=1xA*&MqL&a7i-X}Qgqr_BcPSu^_6>+vlU3J*BcmBPr<= zikCut)_U1S&KZGtf>{lSd%1Zq)I`;*3WocKS6<+9bNEoXeQ7{I%%;rvma53A8Jq&< z)=J`h0SE#dnKy6qxscV37YN5Yrauv%eLv{QC8K z)f3iz)ye^(TnWMnf7&{$4&Cf2q91py^v9P4Aer6a4jOm|v?C}JqvrmU#U!a-l;uXN zlvW-qN&9bbl=(*bNup)%!o85;t699g2w^wA@x^_~>z&PV{9G3I_F7(Ckg(3~8mLuU zR%SUF@Z&{CI0oWlCZuNQrcX3E?M1ri@u^;2*;w;OaTnE>pu)I@&tOR&&^-x>oC1p) z$ZZ^|!=y+D%qU32v}LB)TrDJ@y60XgN;l<0^y|3c3$j*iecgXkylYbF^Y2G`qcNQR z7*O2+9pUUVYA4!<+Da#BjXWc|c`P+g9HVjzWT{aE7xn~D`_5ft#^yK^3L+nh4$(3U zYK*I7I#ckP)4|YPM$PaHE4`Lyf5<+=@6ar9;I}={W`i$vZ+Qe!1bE3wyZG0CN*QB)qH+7Amt~>19Y+Y^gIbu54J>I9g>Iz67I;( zN8BRp4M@sZ$JAx(_JK9_3sM$T@sJ z?fpc|LX#?olIcOQOCtk?^l>6_F_YT|2T7lF3YKr5tlCqJ!kdu@mixZrKZ2-O_#I0n5QsY!IyNStheWy3Ch+iQS^4Z{%neHAB z3kYF4OEc16v-f#A%9cPojB-+)lPZ{0Wf=aMBeR@(I@d}EJf&X>WEbYIHQ z-l(BjD=$k6_Y&yG#?7l(d)1VE+$#^2o{rRf@r_C!J#|dY%>xo(+`4Qkhhib)+mH~F zMvo5i+`h5q@YAn6m@}c;wvQ!3#>On!n%?@c{faz!^|XO+-0TM=h=eO9b9c2qE{1KY zkqDZn(C2nec&|pZ11}MCUGk%JHhnHbA8%h1wqGSf;L)$o8mrE( z=KoCX0CdfL?q*v_zlco$BB{4M|?Z06B(T^2gwwgJcp0X_P|BbUy>02k*x}pn#?@H zWLmJ|U=mu)zIt&9esig;xki8@0`ZO4jI=pip#q$3p8VHbN!W&bog*kkMYn^^d_ zWV3QTJ}1)Jua1)miqAPRS*qbJhTiALzCP4mv($SJC}y87N40~%AwNrTuSlM~Zjl@C zMv3Ngyp$ZZbX;G=L_dW$IxgNSrPj(%^89?sp&c`66dEXWQBzPijvGs*wA3a_Wh>V=)OQZt_znwNtxq5y}g1G+!JL+ zb3 z(3TfLY9V-nOa_1N0gHTO)^Cs~$;BPC+-&8w;)hzt%f8c7^(BnkuuT%l@R zxF0~otZ(=`2Beb(DJ2__x-}#z@1lrI;se)NasdICiBFpI)fxLO|pwx zmLif7%s1)eZKYB<-r5IUpLMVT^Uy&FC?FYzKoR8719Qk7qJZJ(7#NlEP(d^%JM^?fyw#)ex#_H4fv4zrT?5+2_*BFi~vSzlmZL@P*s3~NEraAaQ8?~bjy$; zaihbR%Z)UROENDnoo3!t?ES>@ux%0JQc}?qc}cUmjIlfmc7n%>8P9Vb1bDKnFw--* zGn8##Ji)o|F}Jz#dp#LpGJqRw^t#5BCT8+2zofAV))@ZqJu5rLLQp+K)-MuUV;m&Z zC*(=h89EwGm}v8j9Fb&RN*_8asaq9s3it)rB$Moaj-2SdX8@(reGgcP*#=*_*Cb-- zIr3dmtkSjJ4})QE4x{iluc6oqQ?5`SpT&<%(F$0`YXbs0Z9I`hD`zL^)m}1iP9!T! zFE5XJ;0xPwZL}%;|AfJ{dun9sKfTWO2d*QeZlW=!>_ogrpcJ2_+f{MqG$a33j~^p7 z7lyNhV^2Rfq1pNzRpfv;9b;gBH64I4yge>SO@@oUII*S3o6-28~Yd(h||FQ7rr zyQeR!Aqj3AB00iSW943DY)qC?G05tU8C|H+tN06PPE!muv7E=$QyY{qgzPq82kpEH zF(YYh{y|Uvb^4rHnl(yP*YRL|mOZUOxrDf-0Dv+~{k#NM0-Izv>%VHMrg8*QSDwmY z8OW2HL_)g3gGyq1$abe`8kqxNudu4(hX zMQ1SA+%dM?0bUmAeQ~1UhRo;@IdYXIL#Z5+J zK*BTPNGq}6C@V=SwvejWys69m@dlMzfJl}CABuABkGm7=4xN;rs!q-6J-p!^40zI~ zOy^#XU3s5Q>&&GQyo;!mMEb5iz_nDP>di07edCs1dkQq)a2d=Cg!mJqBxKlL8atV=^2uLZZVt zwec*4$hcuLqWr-5)kGSN#rjP~#fp(XDXrr{T+@szmElzG7GQ^ulKUWF&ET6Chb1{_+>67pphkH>Qw%~-BFCK48}GF^VuZ_ zCfwV0#{psq_#Nj^^iqbnmPaAkqb(Q8;AyTLRK!$P%Hwb-Z_Y1J^_ooVwc;J-LRfu(MkXxd1d^2hJ? z@#*Ljca~N6Ql%Re^e_H9y)cxNNMA?fY4uqgWsH|6wJF$L8%hpw2>H+i;?*m48}p{I zKy$xQWcwoT_hq#gnwgo!C9YwQWbfEFr3+IB#z%K{E=mtdrCm~4`oknU1l(JKd!)dQ zb6GCU*>~E}+t15qO_A^sjh{fU=bgNlgy}69-aV=a2 z5Ot{$Z$->nd z5@I8YZ?>$hK)`JmsHi{#ITd&C_q&0v6x5a=R%acdO$b8R?mT~o_fGcf8av?`!w z%>o#5>Sr=C(7N%%<>JZ$gw4&o068(xK~BXD(bS7k2t>u&mYc;9v4#XK0b(I1lk7tA z+-*%-aK~H`3F0KD)6zLws{t6k0(2UaA;>0j6uAYJ3`qb)HxWR!1e%b)LpuSW_~`uU zg&+aFvLl?5NEM{9$t1lK2sm0X-FG7W5m&LkHDYGVFZ@?#UPk3KK_p;S?n6%5OixVd zr=6yk1&X&8OuO1eBasZLtMLnT7h|`>4@>c$9G^ccflr#vo0l(u!YUrG-;^_BZ}~pJ zoS9PAR^FDodCS&H7_{;+Fjzrc6&M(kghluE^4~OzpTFwVKs86%HxGF}6i#*c0#^-n)CuOwFiFg@T&n;M@wO zg_j7ZIW%9qfjP{cIEIsy)0E_0Fd|!B>Psx7Dr^IJGyBe$ipRafnOI3=T2aV3Hsk+u z2(yB=;{ZTMnEaABpH9%Qrs^Xzua+e^LNa0bCU6 z6Us!Syph1~2B|ZMafa}Es}##POL)C;hAB_29A`RwTawN;9WoAy3ITZS#Jez|vG$GSEZMr_qS!PZw%Z%p~B-(~? z97cmTS~yT+kmq>hDmr9~lZvex){`Tk_JLLfJ>=taB(AJ}8)UW>__8oAO~OxAL`SB% zMauO2JeNkA=9WARy;vsKH3TafP9Y z5n&ONj*e#ysK`VS{zLMmfbY8R$O`SZ?+ki%2cbX!TpN;6W#;7dw*Bm)K}gF`dz2?==SV4?C;cd zo&r*$blY^mj@jC}nwn)bwKa1O%$-|bUt1S11SH4mcIa*&0`ozz03E6So+> zoBmZq8zNC@gyX{^&M||}+`y1*VLr-zZg?2*%-QA<@jGq-d9{B(O%py!`}+n9xQH0h zZZFDDqRA~hwnY*zvD;~UfrTzGs-lERR&F87tx+{|>1#BKLl1oC_A}{GMA8X-!&R2p zy{iX?`Q8#+1wbaeH0_ni6|EV(Kz<)tK_V&1&(^<s_zH3ZOAn`F?4Th>n3_u)jwN zYXJ7@4WIhy25|PFNBr>}ya_=_Uf=k-=_&@5_35Gr3a8$B9;N-f7LnEM zLq(YI=sA;1=0gzkQSi_T81ajVPS5)yAubj#h2sF=%11qimm^#GkN1?y<>TarZSB;L zM$o!+d~ld`oVrWvVB@ z{LLu?Zv+Nah7N^Y*f$y2^^K7KXT$|qD7p-V$<_j6Zk9~1u8D=h{yd&iQCjT*zWuK- zNSOAdsZUl%D%Nk!-t-Yrki;Un~LFckjx+x6c4Yk;6PB{s4R@H5NdB+xSBQI0&R6cGx}#vsS^ zLm!^o4R-G4l)dsKB76EQyAADQUm8*P-ly&1__lX;1BU-X#A)3)75j9!sNdg)-@IQY|e!D6C2SCxw9hC7>DrH59)L!l$884+p zHgmg4*Tb;z2bBeiQ}?`#7XlI7ia(JBiZjc2pmO2A0~TzY57jDE4FBOJ5$*}?{C6CU zBfau{7T{}!4g50p|F+rIUOF~+xCO=n#aZQf854m~#b3e##aU}S5Lo!@c{w}X=Y5kS z=hhuvS$BN5(#d1M2G+mkAYkxK!*|HnV;C*dJ)pxAzIKC77$y|_wDvUqGckzeG z5%ct;!XM+0>nGq&USeE_%)`@>6riT7M1%i$hEE+llK%(){sfe8vjYXnwZ>OfuyX(} zq``atIQ=(yZ*L3w`!NY020+S(ukwiXdCzih&TjM9vHHg+sHi)aD0jYWUl#yi<=esk zzX5ES13#@gz$*ZT1JYk+`AfbvzD)NL4Vf~}NElo^zj!5@gokmwKt zVJFgvEMhQmoH%=3uba>}9{Qj5>?{eF;W2m)J^_F6;pKC%SVP|VNYx`vk90lK|H$|w zbC295{wKn}x%%;OZ*M`gP|Z2 z#Di3j1ByW<7y}wW8|VZxz+7+$I1-!ymV)JAJ=hGc19yNo!OtWbDVH>fw3_sQ^qs6B zk0ehZmp*dLBX1?2C-+clX%@;D$~MYd%Ky|rY8-V4bu@J{wL(%uo&RfLC-poHP0OW? zr_H9Vp^nCgV`ULtM`f~a)`W;3xV>hFlX=W~BzGC@UlUa4FovaSl8P-p> zf?dTv$ANQFIX=!X&R9+*XANfu=OE`i=K-gm8^R@UY23orKOS?q%elL_H@FXYK|CTa zikHO8<4xqv3Z($R=;?X> z^8MR;4!yzVg9%lfe}H^=ckABnD`lAvCCT`}WC#`|17A=N=k?xR4Z}F!M_y1>BU#b9s5R(2|(>nKe^+>yd0u;<%g{Xt44#v)Zmo1z5>v(cai95aCk=Vrq ztz;ko=i&idq)KHJ{@&=b_%rNO_vz8_e;_biOZoN~G~R>3FBpzSb@5TQNxd%8tz$y& zE<=$Sm=EdN_2Az7K5UNA;(Q-#W<`;I8-Ea)j;g8#gz|fiO#qYoXPbT-NLp>*|Agt8 zNYhME zB`mA2SXLTNk7uynyI4vR?yp3$4R=?+f&gh&5O9L^z`}tzfOV@`+|dc29%UO=VbSK! zi_GEUyiRCTh*}Cf4R9PB#=lQPkLX zRJlW9Lt$P>V^>GAE)u6+Iz77ICc9Vztjs)fHTxu^P!v;<&uF6oL$1t2bXl~-iDX~F z$f5g@P90ZmaF_WuAMt9oXEvggIMjo8nXf#$=B?@KGoM!UG9a1QQ?Mv0F3UN_M0Lf5xFQia9nfv!lcj@O zcc#9J98w3G15Sg?>msPomV8xsAX*Enneog44JCKPALYf;diqAZuInH_r~{t`ysbg9 z#Ze6m9pRz3)pb>G!5C|N=z3LoQ5C`VCDk}8wy@(>$KiN`WE$Z)`R-V5V#xjOJN45d z`>*yu>;a|}gfc=uY|W_=R3|yknwzRxUW8i8jsGL>1wYjcNdQ_ub)9H0(34V;!&CAz~By)lH!O(=?KreuVg^3ZYKJ%8N`m_@*mB53tw ztGi%{MdBQW)*@Y&VTW^*Or8j!teS=hez1=Ull&i z3O~@#GOVE3?&AW-(7c4g^1$~;O;^!%(NwO0P6pDDbeZ=2e-$V4tlXQPhq6R(+?}r( zGJ^@|i+u9z@X+05-kgQr1F1%Y)~Yg| zR$vMYeKKgm;k5UxZ}Dod0Boe*l?lWK%mYq?u{*~{MXK0X4mW}i$F+=EvnXaTPj&FZ z*M$r5<*$Tv8?M$H4G@xnB_}|=ymzG$Le948-80yyy7RAc@!E2?`?_-g|2fTkej}b2 z&22BPsy1!#OyRiCG5;yiYC4`w?k{-A&ZDaUdDG4zvrwwU=Zk%I)4z1<=2N?3(IfZQ z#PWsl;r`&E&%X;OM&$Hw7c_E7{UYyEyHoJmJ{8+WmI=teF2`#>*|L3I6wG8$g)V-z zbv!*yTZVB%|3tbR-zrB`L1D_~VAUO(qKg!DTO=|8`>j=7mVi40b!Ifdj+Z!|IG9p4 z%L^S}(UE0~T|XTzbFZdC2uY;!Vm;`(!jaf9-SC8{#uo~VYrKa2QIckQi}-S{%NuDh z`Z0*f$}3Jd1Hv#zVi^qk(Bf)BK0G6zj=2SvRM{-LGDouWZAt>U``5)hm=rmu{*cj}?R#84@+m35~(LG=V) zdB^sS^+lTV+Im(mO=~=Ub-(YVo`_<2`5OLq&HucMr_v~w1X6_^=H@`zckWv^y^0VoX?d|7e?JML9f$tfPsv!hWT2Oz(44a``nB9Dg2k(G`%tB~zR4s*b0)9=UxN$8%>4Z%sj z%zY^TgDR?s;gh&=QfUD&lJI5{SK0e$*Inx|RfOm{xOWZYST4d20P-&_E;S19{Z8(x3w{|^ z#^c|jpAc~4fYTt}rrwL=Fq)prFBjX@VOQBbOcPwVtJMif5gPC%1FszkP1yNLME4i- zFKCADHUsCKbWdIITHb{;jmPfs&xgy^a$Z>?KSe7|5UAvGUa_1m7Sril-`o+N1+HY^ zEtRJ;@B&S>x*vk-SdxN%wE9kl9MgncU>HFan}&$#2~(GV4HZ1C`Dx1?G(!=-gQaTA z+n;wWSYH3o`-iEHr;!C`-Zg@A*bGx-CP=m$=a60 zFi#LubXj&i^~!#=TJPt}sdk$cQ7CW!s?&K{dSne@u9>diUP+frO}hu{=Y8(`QKEEK zf}D7-%Y{}4R@p(Nq0qE!4hBqZK8)ggA6t#WUY9#esh5e zm}PE~)CnQ)Z#S9f1EWZ@lxl5n7dFn7=gD9RNfg+MrQN9X!IUw!BtN)dT-$2vu4)7sgwax+7WZ)lto;P%kU`f)v|}<(YPrTmYZh=;f=(T zghMFR#OoNVJ+J9+UH|4^xrim+n_dgMFo4udU=jm_aYnaDM!uF5296l^qcTK|5(_hw zVH){Sv^{!;nrqanXbT-WLn+L7N^{5a!YEnC#yp;v73p?|&g`s>V1SXl{ztmGc zB<(oxOeb!_XxJ=4&~H<}9D|Y#E_x#IW}XALq)AU5v|7hg)GR0z#Bnz*A(WOw*{jwO z*^d{9QFhJjaxDcseL=3-^}c0=N46iL!xvInA`TRPbmVot@Rh~#sdj)t7O@~q0p%bV zTDelN9fuy)Br!k3_)lg@@)=?_z&zIGeHDWxCBrZWz9ThV8?cy`Ad&XOLuoCDd|mFT z6d}vDIq;$ZKLp0JgN5);Vy&%^Fs!f4*zQOiM&LL{W!J^8Uuun~LGX_gBgz!kG@4rj zlUj0|RxH&h$RP<%(*4$;rSDE)TPB1fW*ahVYf> z#Wnn#xCw(^qT6zB$Qwqi zmD)}*eauYa)(BQVja}2wMZumUX_7g|SK=B^#@Qd;Grq5c!5q{Tf6l&3-@3tSRNrhj zq{0z#M6cDCgPLf_qXbBpR+1@Lv+^v1_NWlNMhHZka!eC1 zm}-QT7u`sm_aYq!yjh)U`)gPwM614SBVo?6Q>}H|4KQLv{m5h~+Btw2grC+l%VxA7 z_GudWB>8vQ7sN1Y!mT51VO2ZMfL0P*O_Csd?@>ElQ09>EZUjQ-lYGL3fgu@yA?Htd zcU?F);3Gv&q$p|njSzblioSAxMC;o6MI#RqbfqpwQRsgj@d7$SXp4TAOu;TQ-x#== z9W)M%4oDBm2?SRbvK5p+ii>hKse*(_($?A#r0u&>9O5e{q3Y&ECrJAFDTFP>ZE3Pj0J_U5pT4dF}TIB%n6WO1T#qTl|Osj!^FlPw{0p;hjy zKAD5jzA##)x9=8>;a{>(sw~Pzzc;OiojwZt%3mJ#hoebnI^=$nay_aLEBN83)w7ii znuaoy$VP=9EbkerBAd$IW&3txjtQ#ji2Ey?$1h9VRW+#%5mLw(F=M2*_MA->_&lRmObZP211o1b~ zm-sMqYy^+_Qpdj<-PJ=yYI5oG_SBA^2>aaNW)i5tm?MtA2qEH5BPt;(H^uC~wK2y~ zTTJ8dz>TB3OX|i#8N3+VZr7R>313WNYQl(vD$0(~xS-WZt7)SlNuMRwZL>dI<&rTW zKgdskVQV0;E(8>jQFC=1{bJk+yfQ3`xhuwi0c_pYI8t!g8uf+oQ*j})V30pkus^HB z*U~xLy#UEXjY3@*z_=CI#?XKlyuhdWS~!QCB;LXtXPAk}3`m|N?a+0GIF{^q`JkbY z<(ZbSd{JZ@GV}H3O?b26;->+9{44*A)&xv#I2ek1|0{j4P|=hmMP(X8)0Xz+@pxz3 zeQYhV8V}EZY)RvOE=HFGEa={7T200tD#Ae4CDV0h&xYkWPU?wWjkEGNl5?y;Ay!ee znCm0*qLX@hlwkEd^4Tjtb}>Hn?RB}lw5roVs;}!%eSBQ2!jxcc<0)Cp!3$mxFnukU z!7B6`hFJ@Td9J7ogK>FdU!Hkx+a;|1AEJ8Ql_fK1b_n)U-D>BzdV|4-{H?mW_mosT z+q*<1;6=hp90%!VVeG}7LKMrQq$cymgxV^F>IE;1QV{!cpG3kD<0z$AP2~&8Fmc2r zvYRl_HB9mb{CvMZ9#4C5Yd8kYdrmg(=l4dFNshm>Nc1u-ezIRIE`b4-xS3^P{=Qaw zmRpW2({Li4E4rGvQvPz7q($qcGZWF0Il~y>t;VxiR)!(AVke4=TuD?8Oz{`nOqy0v z(j?mG?@Pv$C^|D5hkfH}d{iSXPX#xIEQNvV|12HEZ4*+)K^`LjN|x~33DhsNp=hBn z4(Ssnj&y~g{mf3J@40yFkgi$a9tCJ7dN);G_Q1ZDNXahRuiN8fU z{nB8~xELwdY|eHefe8j1{O_p5Ojlx0E@C z0irBIB+XU5zCUFIVH3U_MNafNaRWIV#j%z4v;JW@EHQ%OgjtaWbfX2Sf?=TRRqf{9 zb!HqdS)YF-Nk}q@TfS??qN>R>F8I1TVxE3iI!D;_WR z%i0^V9xb<`=y^dS>owCxBcbU(p#M3@pIOXjqnxHeBJx(_$T!u)wT>vDv+zw>Rq(5S z>^iz|7QUrwx)YwU{U{34wS!sG!2aqh0^bjd77SxHUZB4-)Cx}Ex$?kc;E0fKoetrb za5|jMc>WFDvcuB}$1q$sDr18Jr|5V@oD1Xw^B+9d^jk zR=C4%`*i{B(D%R^Emi?*P{Q z(f+&+^GQauUy3HM0w|1)$)y|jmYWdH=y3E=<92H;F(*COtd?ogm}d0OHf-lR#UoBw zX`7mNT-C#h*+@w;HYybQ;v-`0Qu9qf1FQMMz+zk0$TkmEPUumYgIyA5Z_7a4;74-_VpY3<)$UU+mkHmKj z!K`{lIe*p}E8Uy&McLjhzuE;bm=V$LmBdbk2c^6rFjspUa)(&8JyDQidTU~XB)5_QbEM< zLi05OdFfTRF2@b2t<1Qg$nxkvro*J zW9L%uX#dpFIv{%9w32;M8rN+GKa2H{;fuGj-6KAIPr$AH%$h~@r8zp$jC7sBN$oDT zl+9d3gy|c@3Vl90ie|}^-l!E0t4CsJZ^SQZ0GT|ayPoD_g!^HO2=d}H%fV4|@XAKU zD-=G3VY)8{L^MmrC&&kCT6#g*UtlOCH1_M<1^nrS9Sl}0IA*tlj~cgHX}*d~rLxW8 z+bp)jiS~B;wApJ%g1vuTZ^l`%em{Kj(i6S@bKg-NTGT%(PMD=;$?ZPD2$mU?c5=j2 z4?TOQpgDBn!f9Pgi93=ctHXh>t_KZGqm#W>6raQ8W*xWSU~#p0ZmKS4@`m1#9C{8r z_DDS8m~A751CGZk%euhL^Ai^KS*H_li!uizu4;O?I{yzST0o}(lYjK`WliWz-}#Pj zAL`D3Tmi*r3F06q#z1Jl6I&dr;%g2J+^WG!TWot|-+22`tFlspX=+$B8vNHMe5%uA z#oU(HJCjOe4-I$q>fUO@95Z5}9>ty?g6F>JmbO2Mf!^T`{=GR|&MAl_Q6N!dDow|* zKmSnCPH+%*>W{pxNyp|zKdmK1e{oT??tr@o=_oKh#|Cdpv}E+oKz)6*Yu~$+yoIab zaMp75nQ?=IxL!*vo&HHa-(oA=amQJwU3Vj7SKQXE+fCP9b=NI>9(wEk;TzHH!oBb~ zksG++X#iPEe{}~ome19(&zf=7I)Z6n*Ir3{#}AYFGvSytOX>}|DACrB`J>}^*ldLb z8hXcSHIp%CNTrmA$c^7`G5N3Z%KkCFJw2N~523teKrEbKQF_FG{adk^nIXM-cy6L1dUkYPi;VsLJoXm@kx5dK_+JF~U3a_kIscqhdq^)VY{@2U(>0l41jea&5 z*kZDfAys+2ZibevWP25d%ep95SN8Ws!G}0-uONV_&8U+|=9+stB)Sh^ALa~XUx(+1 z;N{JaY2HF&^A&G^2Z*=(0p4rW=4;&8z&-Mgk_EbEe<~x1+W! zob5mGz9~G&(qdtnel`P(W!C5ePGpabXnl#$oQMZ0svR>wQES3J0I||ZgHnY?yFhSk zU3kV8`w$%{j0t9>SF}08;-nIcl7vPT#--Bw_t|W^s$rVi@k6VG&}u{Yqfc+K8Yq?j zl}o*|><(H<+pngH(1IjiAF0Fne6z2{3RDs(G>w6s*G5CVwQJ?uh0UH;oI=>0 z?%XVvopl{K(u`A4rCV7*q(v0s3E%GaQ+m=Nga}?sTOd*|E@Cx%{+iRs~^Ll}?bN#SvL7pQY@~s9L$_FVfM#spS#&olT!UN&v zX{{D3s7KDJBhaYwb`Ihs@zWZE37b3gk(7y6Y^U>VEua1tCtTr0JMwwyFpi_>D}ion zd%*O4T<2uO-=LO=Cb~!xu)oG+07zbO6X<5 z>;CTaCNa8y83`wR&o!>or|u`WUWwmr4{5HSfY$l{e~ysK_NoHY z3r|?O*&VRu!5q^b-w8iLRI^D{jhANXVUD%Q zH~U1y@uKoM3J;Gj+z_R$Mw&#}p+DA;&K#w4Q=YvnikAU*6IJbE{c<aU*bFr!O*Ia1L(XTLIHK;RJjpQdCus1Q1Snie^@dX$ zpN@-=2Ym>Z(S0xyTT}JsxpaVxo0z`8!?Wt>ESc_@eF=&P9m0>ZJm?I1#F`6_K7yiF z38o?&f!SF{>iM)sum^1|3`)&zkZL)~@un1GZlWtPSP`20?fce9cAd{Qg~f}l7l%`o zuBN+8fOrIROC%5W0v&ds{I>HPhkf8*JtdRq-S#!mZgrN*(=Y}A@>1Zde-=Qw>^4OA zi9LGpeIe(Fs8`BKFlSR%Rlh2i$H;P*>|DJ+UzJ$h@eRAUKNAdK*%o2M`a#4*yJA4 z3GoCv4sdB^q75h84$^AMW}71l`YIBe`wtU6PI@X`@Mk>-nLqFCJ$2yV8B>Jq0#RhK7vm;D>q%A8F}$4KJVW011ELm=cbC<@%Ko1Rx71Br8%v+UvqAAY;#<5 zd~-O@`&&dA2w$l2^2H7|V>9)hn+u!)ME~Ey?=>4w>a;mfe_0#A6@nA&jn_5p&ci~) zhJDj5^FoD-$HOktmyWTumqWe-SWKFoq>C& z`P)x=&q_csHS4H;=%MAgdz`X2v6WEBvd6K=Z4niY7M3ueXxagPDKd3lG`wZHuIE!E zrjeBl&?Ip$%T)}}2eoN4Q8`)~q~t}Siin2prqRGp&ZGgl{tL`>3Okj^)}d{mg*$>y2+Kwu z*?R6yG|F0zFcrhh1OT&Z(JTl&m3m>WBK-ai`y}3BAABR(jTy->PaHgo!-u8!VNk99 z$HR_bXSy8x6tzPh%B{Wd(ot|er8oJb3LDjBplX^5lR*Gh3W<(in9M;5+f7oN5f(e4 zen+~B61o%`Comy5(bb3=fY%hGBH{2YGgji$d<1no$Gd2T)=B@6$juX z{z*=RieYOZ;n3B@hGtrZE}@e1DCtks-nlsCU}@uVlpgnkrlUW|YaifHk*#Y@2rU_R z?E{LJSK}qXOTsWC{+SlD>AIpc10PLH4h;*KY|R6Xq3L=%pIZY+Sap%ANSZ1mk?o@1 zlrAl2jb`S(Eh5F9Juf7y!~wXJ(9An9*rkVf&E5P-dk*kq48oT* zdpo?+6AZS5^&Ya}8ghy0HZglC;H9BJK1k`p_QoD;K$qTWRiz$fw%yNK zxp#);&m7e~#ClN=?a^7NGzWXqT9Zze$BcL>(f9rr3@;NdyJPB+_qMJ}uqZ3*X<7?4 z2zXe-CFjL9;b-_P48CqHvhfITm@qaud8nuv*l_TWLN>$WR1Z-H>5GZQi*=lEA0^dz zF2I8Y9dV;@Re?}ur#L5zT&4*R5LoqftP6mxC3*cjE)6QH6_|reP3Be~?W=DOQRGRU z#+R-o@~lj?V*YHHaNIjY9b`5VMUn8t@PuCJaA+I=p{%FXGg-6yv|Fjfi4mG%F$yM% zfGJX{Xqr*Qz#lw2)l1My&j$UZ3Ls?KJ$|&n^N=_4*SkUc`O)LXDd1?kuca$%#4H|X z8i2yT9R7CFnZ?Bg(6h$oaCfwY(-F{*w@wM{22b5B(UR%VDrMU;-3{;IH39_4)pZm#`=x=;aS zd{XvLX^v&dFjR`ST!c*Llx2|K>6<&_*Y5u2q)nCspE7df_*>tax5Vbe2j2Uf&jX{+)k<&Q(NlPi81h*_DZ&BY;&}2|bju?QD=~^Fs7zR4h8g}0U z^5a?1)OstU9ObYUEPOz6#>av&z=O23AUtTr7#+FmlX1~ZH_{C_G+;4EVn< zw!mcRWXQ2J+U^oBX7;8o5;}h@S-Y$j5e!$nB)V&Ok>|C7Lq$^NSWFmjwnn}XKtlH0 z>;umRnokbQ*WX{d#k`k3W#Nb&&FdxZ*P=${rpON3X}cNf9E+4mEjT&X{S^(j+i8!* z7B=F|Ij!sll}6~mp``CHiS?q4eo%(6J5H&9ZAz~yK0SxB~BL;kTK0gah|6!jUXJPc*JPdLhzGV z-;*2Me7ygmd&JBTTf$H{W^MMe7>f;A^)%J)FE*LM=hYdU{0QC5DaPVySBpm~SDtwV9O`M5CA++YC$6nDQP)j9*3iLN&l21XR zBOw@8%#;P{TR$T40^$R-f&@c|5)DmeqbLAn#!V|x4Lhvi5vaUkDcrl$y$535Q=SUDFU;SV!(>)}wT0lVz^q0R1>!~wO z#~!TVfo*(m3ER5O@glnIiOiLhz5+Nj!h2buJk-c_LtkeHY8%H4XYtF;R&RdVqC84m z)maEylx0QKb%doZ%|#Pyy;>_eVLZDuLt+S}aK0JED9=O74zb3HhNCd9oTMmLoE$SB zr_(KM#d!BLmc)?B3DRnMz+>wPcc5u@RNx+20}6qmIH6@r#krZsjixx;Q)!ZHXAQ%q zk?<5r-oo=@(M3+xf_l|^DG3}+(KKc%hGFTN z;RX6fd3jVyDTBqS9v8ZU!$25YgFg)w?&vbZAV{8g<|Z}JiYiPD{7Buk1~c*u37a2C z14Pj@m69xBUXL*$8<4IGSrED*YDIemC%{kf6_Bq)VZwuqg~vJmbq0OqKa^LvN{c1x z(3^hZ%9xx9uM$150gs_kMrCe{jR>2j>DR9%*srGPa!YO1;iB}GZjZbJByQU3z71TOpd`<*0;8mz^|BUn`vR1%=iU^)p z>k0|E&IIFckDqPz@%J5(?I?w16{UluYr%6w)%Ea`%xtX3qdnPE39Pc(*SMT)*8bT^>HJmJzE z_0aabT(H250aVRIi&JvWWVQV7&!MiaBPS_==-9LL0F&Q9=3J&gK7CNU7)RA=8QtBv z*hZPWwstvqxb8i+Q}_J)&QQ_T)S=R`+3oi+!hVv%q*ji+az5!Xg|gM+2B8(>vH9x6 z`u%z9hvn4%(@lzfB6o-EFliKJjF5mD^N`4s7=wv_RAGCqS*T|5f5hxPg8I2{ zS+k0GVIYQh&piA@|3VT8Eq5sRhe-QlGRy6!f{6>zMx0k3DqEA6W_#K<@MB35XTvio zUP=~|u_Wyl!`e5)K`&sCVfOngD8iU#QKmNk2;-+r31cu0h!SNkEzw=<`JpXa(G=6_ zk(Oh2+th>AsE(oGc)nVd&M@5;Jh636w}uA(*S|V|fPd`9X@v2YR6ptV^3xvYDjwb? z2^=M$&}4ieL!mtIi^IG8oeDAZJjm4}zNh=P9^#Wm*Cwm#`?`EY%l253?2Q?R_`Tiu zEDAg_eIgFm7epaCnn15@eCl-%U+X-5)=zExO~+=BpBRhfNcnuZwOpThDQjs9$C8b)hjV);`K?Y)*|`r$2p zr>gje!#QMoAl@=C_r`<8>DONPzB3&9E&<)f^?$Fcx5Vk+Lf!yfKp*@8P^BW!es&gq zc3}(UKpa2^*FYD{GT=!55LG_94yroK-feyb>pX4%SzJK3DzbKzS4pFf2#s`4>Pj&e3%PdyXsO#L;@iIxP}h5J zo^P)SsKa8?R_FaugUOjcd!c=c!x~V{{3T3q)cDMKz)YHnv7lINA+&)y|B-+H4VFb^ z2e7%oc`+1-FT`3#ExT>!?8m7sfN#LPpbd3?K~KL)Jw`7(OI_MO<+p-b$k7QBK*DM%G-iOY z5Hl>VBdeb_$ad)Ks0}xBhrTLo`l07IULxBXG9$`3VOsQ@R6%;nKLD}4`OYgECw>Ge zNoOkr&o;4P$9a@RVgPpc>_#WtDYtT=98DNkVklg<@+1a!*cQqnL!pj3R(1iQOmIzb z`dct#jBI=$)m9U8Rnc~twML9L+wYB8pxD_MLHa@Ww8bSo`xgB;z2cr2hd%&uDF%FU z6x3LAma1maH|h?!JI3=~M0(!JiabON;*6EoTSt7nDkigCL4AbTB!e=>cpPlsN&py4 z)jQi$6h_$DJunAll86eZy2Ucob|ZFWqT$-GHH@ZiA5}G5k{DjTAdnsE|3}ot*GRVq zrjD4{w6NPP@K9z7`Wds#&5c)#1|kjSuus#-(QhbzS#vv~brt;~_4Rx8*VBqzY1E-! z(z-G|qsQL?PtogM!&H53T~g$n+7oQ1*?m*oHXc%B*rYh0geTe$#!&^T(LjgCVPr9}le3$GSN>GioTd52YqCdyTLpsxCEb^7BOJD=7jU0)__@{4u4H zpi;yxCQJ%-7s#{?d1-#9%2Z>fCd>}foW_-ybDp%1;lVom*--kaZPFsbW}*>&?tP+9 ziR?y#EX3(lg05*QM$wCsR$sXplo9vg%t?Y?IsBI}|GezlJ_{P|X`oACoAwN-lq)>j z4Y=8k)k3balBF3de}JVTmIoSXWWg={8*;*=!r^O;T(Aespa42(U>78VKAn}z?Rb;; z%tOp3#;HDqnVLq^4sbx&%sGI98#p(KgVm=3ulrh3&88v$ z!7f6Ml05~vEsA_9G}Uk_P*ch3x=<^VIl?8?$WeagCJ>i5;^q@#J5uKsZAz%$pzVGA zKw6Ntk>uS8l4haz5LPWrXHhU(mr z=#>F_Bvj}I8={1UAjkx;-4?cs8y#l{Qm^~15%8} zg8yY_I3Is!<$G9xM>OH!8bakAzM&RfaFKwJh&TZ*x$KIoB&1{sBo}FzRM%WrXvKW3 zc2oL9MNLC1O1E2v3yBsZR-6xXfiUyw)6Z|vfPb@a7*gmOn_;8IjBs*s^LSseNmC)4 z6pEA(Qh7t=Qf6AY8MA@{{$qy6d2C8orP@57TD$F4VWv9u8Z^4$pxn@+6+>cZ42$70 zB2W<#=)gGUxK&mM3;nfd+vK2SCmD0R77P|MRn9fZPZ15G(2k; zKcV@Q^WQ%>PRAinNtc}dGBVskT*)nzF#~c-(l&hk@=(9=b*^I%s%|)^_8pfMI0=qF zYXy7Bm&Cb&;lZsMw{-{S=IuxczorlHx42E^rYLd7K*9Cz!9HLpE~L+&*N1sd-~Mt- zJP5B>;TpC96LIMq7P{IvctU?44xU`vL$8@ge9idq*3%|>K(jsdn(65=^+cj4P*2co z?>#|J@Operd4M(nf&_X3JthzcL=p%RctRkcpc!3tRC|M}BCn{&1`(SmT1C6)6kQq7 z^i`Fpo{CY>d|a;ZPIURJbHdXHA_z$vplu_&ajEkMdyEWl9=PpCrxH zPP6Lu%aSId30NP+;rm{!e*-IC}ZP?=zIAnELIYYB`y|fg!YtSI_(vbU14@y2^s9z-((WQSuxrsXYptn;@w)z(QTnmv3fF@#i=?KJz~r4tXxL7`WyG#pohEO=Xv0RbXnKwGb z_)iELY9LzOgwi9sqJXT+45 z$spypxe5?BCAkdoP+dnfiKM92jkZV6R$i#0iG-r=TALxvVo7F^Z`4GaxaEw0qJ0s7 bZitM@#@5GbBJpTH;Smba5>{=f$5a3Svsp}k literal 0 HcmV?d00001 diff --git a/src/app/fonts/SourceSans3-Latin.woff2 b/src/app/fonts/SourceSans3-Latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..864cc4177aab8cc9312a81d4b7b6af7fdcf4092c GIT binary patch literal 28740 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�X(l;LeNBzFcr07D_0E7h zOu`1T`}kKNqhO^??(K{Xi3>Fs#ROcpm>NMX3_#R`;YC(&8h#{{w5>p1FdQS8Jx=@)g|){C(1WMjLW zPnks;(z>|-Q_CcoKhm~ycfPRdmRkpUO0I{h%9_%j0|Nn6y9h+2nQGGa~t&>##=D#aJ{Z;^q0 z!WIXf9Ny?%6aHeO~>+|Hu@ z2mYNg&~PL9a3>WJf*O6FvHyt-9D}#iw-_BkhX%lC%Ngrgm@F+*6jO(H|yiA-8v zIkd_<2o4h01KuREivco~@jQ`i1unAv`iH;KdvVz9AS)Pgzg|n7Iyc2fx#DL!A_mv?@@3%W{Yg+wD<;I1EH&wC!lE zkg)M20p~hjuu-poI|$bSh3ImnO@wS#S!U3yx?T_{>DRC@g3wfJV-11f_Y+jGlyCzC z1VfgwWz)}=w z>Kj^_JCW&SntU!v{v;yN+463Yze1Cs&ui_6ayJ)j-MJ*=6e%HOZ=#aPPSHcZq`SqO_HY6Nqmq8ZTnE+dCm+< z)2>06MvSm<8bgAc=O-_#oKXjtd`;8_wJm>_#W<$Ee2^Q!80*a#b6Ivq6WVOnq9Syx zh!wtUHAW5&v93{>dJ=6M(@+4WS8O=s(=@WTVT2e|wg!AwbycEvcILHjskzv`+ z6pIxjV}QX>;XH;BH`hFjA415?&>#mA)vIH_9LKn9Vqe)+VlHRPL8f|}Zuz~5>rzcx=p4FB{IYpbcQVeS_5#{Wsa+_!Z>~1T znoM<6(x8gal$~_2y)YeOI)=i@WK3$C52^gjBD51jC`2A=P>uFYL&jM)g@N*C{1Uj0 zK(r6q+wCtQ;MaKF;&?xQS3)!oBi9TFU#J}2OG-T*QjVwj^Thz~lwLd7#$%lH}N5XjvcaOxEPXjxDkcaBmI-W9DZ)$Ork#e8QMUCD}DjuoHsn~C_ zM5!*GOJQ@|ct^^pxn39d*3H&5CIUw(vR?~@qHbJ@kL{U6rSheo+E(7I?#+XtMAOl* zn2OS&DDMWSRV}JTl^ylF$8M;9)vG;{= z%nVjtGugMdukEF;i&oM!?S+GSR@vpv5!<4@ z#LS|Y^_P>0W=~Bk_{H%5{4!pJV2Nnin|#uYPstQ7qWZLZd_pqwc`38-91UManDd#Y z7kjI(p)9!HuN9SZ1x;H=m(K87rd1YOk0%HVk+4>wBI{fV8*yO0jW*ci3&r;O(O3Si z1jFfxUXM(K3*4X(5Ak6eUs5u13QB%xD(WPW5Q&gj8ZSeT1@j@|Pvd)peRLX8Si1eJ z%TI<3>+`FFT8VW!Y|v&7JbY2?vSgEZ&q5CfxRv|b78Qb!p&*eda*#Z%KvkqE(Ulo0 zh$>T!rOwviXmXA6jJ3<|gcG;az` zX&3Dqmt9fms_SlKp^ilyqQzJ&R-AYV61^qK63ISP0b>EHh_k4qj8`F8B3f3pLQ*4B zsOlP;TG})nx-NssvdY%uSc|o7kI&*;pG(;Xg|0{)cnR3170sCHwcI#?Kf3g^*@6@g}mSOd-pf z(ki0D8u-ln-KUx1-zVb6|J6eO`t-f2^;)C9q+jyz>fCSa?7OMm34xf zuWW%e^bPWoiDtE0R;GzJnZetmySKHfqv}>B%Cu^uT9{Hw0WIhhz({(&?X1(+D}aQ|0aUA00000u#Or& literal 0 HcmV?d00001 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 ( +
+ +
+ {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 * * *" + } + ] +}