diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..913fd0d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + package-manager-cache: false + - run: corepack enable + - run: corepack pnpm install --frozen-lockfile + - run: corepack pnpm check + - run: corepack pnpm test + - run: corepack pnpm build + - run: GITDB_BENCH_ROWS=25 corepack pnpm benchmark:gate + - run: corepack pnpm pack:dry-run diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..6ec49ad --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,31 @@ +name: Deploy site + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + id-token: write + pages: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: site + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..341d45a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +name: Release package + +on: + workflow_dispatch: + inputs: + publish: + description: Publish to npm after package dry-run validation. + required: false + type: boolean + default: false + push: + tags: + - "v*" + +permissions: + contents: read + id-token: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + dry-run: + name: Validate package dry-run + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + - run: corepack enable + - run: corepack pnpm install --frozen-lockfile + - run: corepack pnpm check + - run: corepack pnpm test + - run: corepack pnpm build + - run: GITDB_BENCH_ROWS=25 corepack pnpm benchmark:gate + - run: corepack pnpm pack:dry-run + - run: corepack pnpm publish:dry-run + + publish: + name: Publish to npm + needs: dry-run + if: ${{ startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish)) }} + runs-on: ubuntu-latest + environment: npm-publish + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + - run: corepack enable + - run: corepack pnpm install --frozen-lockfile + - run: corepack pnpm check + - run: corepack pnpm test + - run: corepack pnpm build + - run: corepack pnpm pack:dry-run + - name: Publish with npm trusted publishing + run: COREPACK_ENABLE_STRICT=0 npm publish --access public diff --git a/.gitignore b/.gitignore index 96507ce..2bd7980 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,8 @@ dist .gitdb .gitdb-example-public .env -examples/express-prisma/.env -examples/express-prisma/generated .DS_Store coverage *.tsbuildinfo +.omo +plans diff --git a/Dockerfile b/Dockerfile index 053d33d..6248aec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,12 +13,7 @@ RUN pnpm build FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENV=production -ENV GITDB_HOST=0.0.0.0 -ENV GITDB_PORT=7432 -ENV PORT=3000 COPY package.json pnpm-lock.yaml ./ RUN corepack enable && corepack prepare pnpm@10.33.0 --activate && pnpm install --prod --frozen-lockfile COPY --from=build /app/dist ./dist -EXPOSE 3000 -EXPOSE 7432 -CMD ["node", "dist/src/http/main.js"] +CMD ["node", "dist/src/cli/main.js", "check"] diff --git a/README.md b/README.md index 188cafc..c7f139a 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,83 @@ # GitDB -English | [한국어](docs/README.ko.md) +English | [한국어](docs/README.ko.md) | [Website](https://3x-haust.github.io/gitdb/) -GitDB turns a GitHub repository into a project-scoped database. - -It exposes a PostgreSQL-compatible local TCP endpoint, accepts SQL from tools -such as Prisma and `pg`, executes the query in GitDB's engine, and persists the -database into a dedicated GitHub repository. Public repositories can be used as -human-readable data dashboards or as encrypted object stores. +GitDB is a first-party database runtime that uses repository storage as durable +history. Queries execute locally. Writes commit through a manifest-gated +mutation log. Plaintext stores expose reviewable page snapshots, while GitHub +stores sync durable state through Git tree commits. ```text -Express / Prisma / pg - | - | postgresql://127.0.0.1:7432/main - v -GitDB PostgreSQL facade - | - | in-memory SQL engine + manifest/log replay - v -GitHub repository +App code + -> GitDB DataSource / Repository + -> Local SQL engine + equality indexes + -> Manifest, mutation log, page snapshots, compaction + -> GitHub repository for durable history and audit ``` -GitDB is not SQLite-over-GitHub, and it does not upload `.db` files. The GitHub -repository is the durable database store. +GitDB is not a single JSON file with a SQL-shaped API. Its identity is the +runtime and storage engine: transaction boundaries, single-writer control, +replayable logs, checkpointed snapshots, rebuildable indexes, compaction, and +GitHub audit sync. ## Why GitDB -GitHub already gives small teams commits, pull requests, history, branching, -review, public visibility, private repos, and access control. GitDB uses those -primitives for project data. - -Use it when you want: +Use GitDB when you want: - A database repository per project, for example `my-app-db` -- SQL and ORM access without writing custom Prisma, TypeORM, Drizzle, or Kysely - providers -- Public data that can be inspected and edited from GitHub's web UI -- Encrypted data in public or private repositories -- Auditable commits for agent memory, demos, content tools, config tools, and +- Local query execution without per-query network round trips +- A TypeORM-style `DataSource` and repository API included in the package +- Human-inspectable plaintext snapshots for public demos and reviewable data +- Encrypted manifest and mutation logs for private local data +- Auditable Git commits for agents, demos, content tools, config tools, and low-frequency app data -Do not use it when you need high-throughput OLTP, low-latency multi-writer -transactions, or full PostgreSQL compatibility today. +GitDB is still experimental. Do not use it for high-throughput OLTP, low-latency +distributed writers, range-heavy analytics, or workloads that require mature +multi-node coordination. -## Features +## Current Surface | Area | Current behavior | | --- | --- | -| ORM access | PostgreSQL-style local endpoint, so existing PostgreSQL clients can connect | -| SQL | `CREATE TABLE`, `INSERT`, `DELETE`, `SELECT`, joins, grouping, ordering, aggregates, and common raw-query flows | -| GitHub storage | Dedicated repository per database, created on first write when permissions allow | -| Public plaintext mode | `table/schema.json` and `table/data.json` are visible and editable in GitHub | -| Encrypted mode | AES-256-GCM encrypted manifest and mutation log files | -| Local mode | No GitHub variables required; data stays under a local root directory | -| Example app | Express + Prisma API using GitDB through the PostgreSQL facade | -| Benchmarks | Local, facade, and GitHub Contents API benchmark commands included | +| App API | `createGitDbDataSource`, `defineEntity`, typed repositories | +| ORM | `save`, `insert`, `insertMany`, `saveMany`, `find`, `findOne`, `delete` | +| SQL engine | `CREATE TABLE`, `INSERT`, `DELETE`, `SELECT`, joins, grouping, ordering, aggregates | +| Indexes | Rebuildable primary/equality indexes for simple equality lookup | +| Storage | Local plaintext, local encrypted, GitHub plaintext, GitHub encrypted | +| Durability | Manifest-gated mutation log replay with checkpointed visible snapshots | +| Concurrency | Local single-writer with multiple readers and stale lock recovery | +| Compaction | Local plaintext log compaction behind page snapshot checkpoints | +| GitHub sync | Git Database tree, commit, and non-force ref updates | +| CLI | `gitdb keygen`, `gitdb query`, `gitdb check` | +| Package | npm exports, bin, pack dry-run, publish dry-run scripts | + +## Guarantee Matrix + +| Capability | Guarantee | Boundary | +| --- | --- | --- | +| Transactions | Mutations are serialized and manifest-gated before becoming committed state. | In-process queue plus store commit boundary | +| Concurrency | Local stores own a single writer lock with stale recovery; readers can reopen committed state. | Not distributed OLTP | +| Durability | Reopen uses a checkpoint only when its sequence matches the manifest; otherwise logs replay. | Manifest sequence | +| Snapshots | Plaintext snapshots are page-level JSON files plus `snapshot.json`. | Optimization, not source of truth | +| Indexes | Equality indexes rebuild from committed rows and are ignored when stale or missing. | Derived data | +| Compaction | Checkpoint first, manifest advance second, obsolete log deletion last. | Local plaintext store | +| GitHub remote | Writes use tree commit batches and non-force ref updates. | Remote audit sync, not SELECT hot path | ## Repository Layout -In plaintext public mode, GitDB writes both internal state and human-facing table -snapshots: +Plaintext mode writes internal state plus human-readable snapshots: ```text gitdb/v1/ manifest.json + snapshot.json people/ schema.json - data.json - teams/ - schema.json - data.json + pages.json + pages/ + 000000.json + indexes.json log/ 00000000000000000001.json ``` @@ -83,7 +91,7 @@ gitdb/v1/ } ``` -`data.json` contains only rows: +Page files contain rows: ```json [ @@ -92,11 +100,7 @@ gitdb/v1/ ] ``` -That means a public database repository can be browsed like a lightweight -Firebase-style data console. Editing `data.json` in GitHub and committing the -change updates the visible table snapshot that GitDB restores on the next open. - -In encrypted mode, GitDB writes opaque files: +Encrypted mode writes opaque files: ```text gitdb/v1/ @@ -107,283 +111,172 @@ gitdb/v1/ ## Quick Start -Install and build: +Install: ```bash -pnpm install -pnpm build +npm install @3xhaust/gitdb ``` -Run the local PostgreSQL facade with encrypted local storage: +Use the first-party repository API: -```bash -export GITDB_KEY="$(node dist/src/cli/main.js keygen)" -pnpm start:facade -``` +```ts +import { LocalPlaintextStore, createGitDbDataSource, defineEntity } from "@3xhaust/gitdb" -Connect with `psql`, `pg`, Prisma, or another PostgreSQL client: +type Person = { + readonly id: string + readonly name: string + readonly team_id: string +} -```bash -psql postgresql://127.0.0.1:7432/main +const PersonEntity = defineEntity({ + columns: { id: "STRING", name: "STRING", team_id: "STRING" }, + indexes: [{ columns: ["team_id"], name: "people_team_id_idx" }], + primaryKey: "id", + tableName: "people", +}) + +const dataSource = await createGitDbDataSource({ + entities: [PersonEntity], + store: new LocalPlaintextStore({ root: ".gitdb" }), + synchronize: true, +}) + +const people = dataSource.getRepository(PersonEntity) +await people.insertMany([ + { id: "p1", name: "Lin", team_id: "storage" }, + { id: "p2", name: "Ada", team_id: "runtime" }, +]) +await people.saveMany([{ id: "p2", name: "Ada Lovelace", team_id: "runtime" }]) + +const storagePeople = await people.find({ where: { team_id: "storage" } }) ``` -Example SQL: +Secondary indexes are equality-only metadata on `defineEntity`. The runtime uses +rebuilt equality indexes for simple `find({ where })` and `SELECT * ... WHERE` +lookups when the query shape is safe; other SQL falls back to the local SQL +engine. -```sql -CREATE TABLE teams (id STRING, name STRING); -CREATE TABLE people (id STRING, name STRING, team_id STRING); +## CLI -INSERT INTO teams VALUES ('t1', 'Storage'); -INSERT INTO people VALUES ('p1', 'Lin', 't1'); +Generate an encryption key: -SELECT people.name, teams.name AS team -FROM people -JOIN teams ON people.team_id = teams.id; +```bash +gitdb keygen ``` -## Express + Prisma Example - -The example is a real API shape: Express handles HTTP routes, Prisma talks to -GitDB through the PostgreSQL facade, and GitDB stores the data locally or in the -GitHub database repository configured by the example `.env`. +Check the configured store: ```bash -cp examples/express-prisma/.env.example examples/express-prisma/.env -pnpm example +GITDB_ENCRYPTION=off GITDB_ROOT=.gitdb gitdb check ``` -In another terminal: +Execute one SQL statement: ```bash -curl http://127.0.0.1:3090/health -curl -X POST http://127.0.0.1:3090/seed -curl http://127.0.0.1:3090/people +GITDB_ENCRYPTION=off GITDB_ROOT=.gitdb \ + gitdb query "CREATE TABLE people (id STRING, name STRING)" ``` -By default the example uses plaintext mode: +## Environment Model + +Local plaintext mode: ```env GITDB_ENCRYPTION=off -GITDB_ROOT=.gitdb-example-public -GITDB_GITHUB_OWNER=3x-haust -GITDB_GITHUB_REPO=gitdb-example-db -GITDB_GITHUB_BRANCH=main -GITDB_GITHUB_PREFIX=gitdb/v1 -GITDB_GITHUB_TOKEN= -API_PORT=3090 +GITDB_ROOT=.gitdb ``` -Leave `GITDB_GITHUB_TOKEN` empty to test against local files. Add a GitHub token -to write to the dedicated public database repository. The token needs Contents -read/write access to that database repository. If the repository does not exist, -the token also needs permission to create repositories for the owner. - -## Environment Model - -The root `.env` is for the GitDB facade process: +Local encrypted mode: ```env GITDB_ENCRYPTION=on GITDB_KEY=generated-by-gitdb-keygen GITDB_ROOT=.gitdb -GITDB_HOST=0.0.0.0 -GITDB_PORT=7432 ``` -Application examples keep their own `.env` files because application settings -and database-repository settings should not leak into the package root. - -### Encryption +GitHub-backed modes additionally use: -`GITDB_KEY` must be a base64url-encoded 32-byte key generated by GitDB: - -```bash -node dist/src/cli/main.js keygen +```env +GITDB_GITHUB_OWNER=3x-haust +GITDB_GITHUB_REPO=my-project-db +GITDB_GITHUB_BRANCH=main +GITDB_GITHUB_PREFIX=gitdb/v1 +GITDB_GITHUB_TOKEN=github_token_with_contents_write_access ``` -Keep it outside Git. If the key changes, previously encrypted data cannot be -decrypted. +Leave `GITDB_GITHUB_TOKEN` blank for local-only development. Use +`GITDB_ENCRYPTION=off` only for intentional public demos where table names, +columns, and rows should be visible. -Use `GITDB_ENCRYPTION=off` only for intentional public demos where table names, -columns, and rows should be visible in GitHub. +## Operations -### GitHub Storage - -Set these variables in the process that runs the facade: +Run the bundled example: ```bash -export GITDB_GITHUB_OWNER="3x-haust" -export GITDB_GITHUB_REPO="my-project-db" -export GITDB_GITHUB_BRANCH="main" -export GITDB_GITHUB_PREFIX="gitdb/v1" -export GITDB_GITHUB_TOKEN="github_pat_... or ghp_..." -gitdb serve +corepack pnpm example ``` -Recommended pattern: - -- Source repo: `my-project` -- Database repo: `my-project-db` -- Public demo data: `GITDB_ENCRYPTION=off` -- Real public or private data: `GITDB_ENCRYPTION=on` +This builds the package and runs the plaintext API example plus two encrypted +API examples: -## Runtime And Trust Model - -`gitdb serve` and a hosted GitDB endpoint are the same kind of runtime: -PostgreSQL facade, SQL engine, and GitHub sync. The important question is where -that runtime runs. - -| Mode | Runtime location | Who can decrypt? | Best for | -| --- | --- | --- | --- | -| Self-hosted encrypted | Your app server, VPS, local machine, or private infra | Only the environment holding `GITDB_KEY` | Real app data, public encrypted repos, private repos | -| Hosted plaintext | GitDB hosted runtime such as `gitdb.3xhaust.dev` | Everyone can read the GitHub repo anyway | Public demos, public datasets, inspectable examples | -| Hosted encrypted | GitDB hosted runtime | The hosted runtime must receive/use the key | Managed convenience mode, not zero-knowledge | - -If you want encrypted data where only your service can decrypt it, run GitDB -yourself: - -```text -Your App -> your gitdb serve -> encrypted GitHub repo -``` - -Do not send `GITDB_KEY` to a hosted runtime unless you intentionally choose a -managed mode where that runtime is trusted to process plaintext query results. - -## Architecture - -GitDB is split into three layers: - -1. PostgreSQL-compatible facade - - Opens a local TCP endpoint. - - Lets existing clients use a normal PostgreSQL connection string. - - Avoids ORM-specific driver/provider work. - -2. SQL engine - - Owns schema, mutation execution, query execution, joins, grouping, and - result rows. - - Targets the PostgreSQL subset produced by common Node.js clients and ORM - raw-query flows. - -3. Storage providers - - Local encrypted store for development and tests. - - Local plaintext store for visible snapshots. - - GitHub encrypted/plaintext stores for remote durability. - -See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for more detail. - -## Benchmarks +- `examples/api-local` +- `examples/api-encrypted` +- `examples/api-encrypted-reopen` Run local benchmarks: ```bash -pnpm benchmark +corepack pnpm benchmark ``` -Run the GitHub write benchmark: +Compare current benchmark evidence with the previous documented run: ```bash -GITDB_BENCH_GITHUB_ROWS=2 pnpm benchmark:github +GITDB_BENCH_ROWS=250 corepack pnpm benchmark:compare ``` -Latest measured run: - -| Scenario | Rows | Write ms | Writes/s | Join ms | Reopen ms | -| --- | ---: | ---: | ---: | ---: | ---: | -| local plaintext visible snapshots | 250 | 1443.90 | 173.14 | 32.97 | 140.05 | -| local encrypted mutation log | 250 | 761.99 | 328.09 | 4.31 | 322.51 | -| postgres facade over local encrypted | 250 | 987.93 | 253.05 | 19.74 | 0.00 | -| github plaintext contents api | 2 | 14157.49 | 0.14 | 6.70 | 1812.62 | - -Interpretation: local execution is already usable for experiments and -low-frequency workloads. Direct per-mutation GitHub Contents API writes are too -slow for the hot path. In the current implementation, live queries execute in an -in-memory SQL engine restored from a manifest, mutation log, or visible -snapshot. WAL, indexes, and batched Git commits are planned storage-engine work, -not current guarantees. - -See [docs/BENCHMARKS.md](docs/BENCHMARKS.md). - -## Performance Roadmap - -The next performance work is storage-shaped, not facade-shaped: - -- Local WAL first: return success after local durable write in `fast` mode -- Batched Git commits: replace repeated Contents API writes with Git Database - tree commits -- Snapshot throttling: refresh visible `data.json` on intervals or explicit - sync, not every mutation -- Chunked table pages: avoid rewriting a whole table for one row change -- Local primary and secondary indexes: keep joins and filters off the GitHub hot - path -- Manifest versions: skip unchanged table reads on cold start -- Strong mode: optionally block until the GitHub commit lands +Compact committed logs on a local plaintext engine: -## Security Model - -Encrypted mode protects manifest and mutation log contents with AES-256-GCM. -Keys are never stored in the repository. +```ts +const engine = await GitDbEngine.open({ store: new LocalPlaintextStore({ root: ".gitdb" }) }) +await engine.compact() +``` -Public GitHub repositories can still reveal metadata: +Compaction writes a matching page snapshot first, advances the manifest to an +empty log list second, and deletes obsolete log segments last. -- Commit time -- File count -- Approximate file size -- Write frequency +## Documentation -Mitigations include batching, padding, compaction, and opaque paths in future -storage versions. +- [Architecture](docs/ARCHITECTURE.md) +- [API](docs/API.md) +- [Benchmarks](docs/BENCHMARKS.md) +- [Migration](docs/MIGRATION.md) +- [Release Handoff](docs/RELEASE.md) +- [Release Notes](docs/RELEASE_NOTES.md) ## Current Limitations - SQL support is intentionally limited to the subset GitDB currently executes. -- PostgreSQL catalog emulation is not complete. -- Multi-process writers are guarded by GitHub state, but this is not yet a - high-concurrency OLTP database. -- GitHub Contents API mode is useful for demos and correctness testing, not - production write throughput. -- Public plaintext mode is intentionally not private. - -Unsupported SQL should fail explicitly instead of pretending to work. +- Local writes are single-writer with stale lock recovery. This is not + distributed OLTP. +- GitHub tree commits are for durable audit sync, not the SELECT hot path. +- Local encrypted compaction is not enabled until encrypted checkpoint restore + lands. +- Public plaintext mode is not private mode. ## Commands ```bash -pnpm check -pnpm test -pnpm build -pnpm benchmark -pnpm start:facade -pnpm example -``` - -## Deployment - -The deployable service is a NestJS HTTP control plane plus the -PostgreSQL-compatible facade in the same process: - -```bash -docker build -t gitdb . -docker run -p 3000:3000 -p 7432:7432 --env-file .env gitdb +corepack pnpm check +corepack pnpm test +corepack pnpm build +corepack pnpm benchmark +corepack pnpm benchmark:evaluate +corepack pnpm pack:dry-run +corepack pnpm publish:dry-run +corepack pnpm example ``` -`pnpm start` runs the HTTP control plane. `pnpm start:facade` runs only the TCP -facade for local ORM testing. - -The current public HTTP control plane is deployed at: - -```text -https://gitdb.3xhaust.dev/health -``` - -`gitdb.3xhaust.dev` should be treated as a hosted GitDB runtime/control-plane -instance. It is useful for public plaintext workflows, demos, setup flows, and -future managed modes. For encrypted data where only your service may decrypt the -database, run `gitdb serve` in your own environment and keep `GITDB_KEY` there. - -HTTP deployment does not automatically expose the TCP facade to external ORM -clients. For remote ORM access, run `gitdb serve` near the application or deploy -to an environment that exposes TCP port `7432`. - ## License MIT diff --git a/biome.json b/biome.json index 0359bcb..a57c9ff 100644 --- a/biome.json +++ b/biome.json @@ -43,9 +43,11 @@ "!node_modules", "!dist", "!coverage", + "!evidence", "!.gitdb-example-public", - "!.omx", - "!examples/express-prisma/generated" + "!.claude", + "!.omc", + "!.omx" ] }, "overrides": [ diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..ddfd972 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,153 @@ +# GitDB API + +This page documents the public runtime surface exported by `@3xhaust/gitdb`. + +## Install + +```bash +npm install @3xhaust/gitdb +``` + +GitDB is ESM-only and publishes types from `dist/src/index.d.ts`. + +## Data Source + +```ts +import { LocalPlaintextStore, createGitDbDataSource, defineEntity } from "@3xhaust/gitdb" + +type Person = { + readonly id: string + readonly name: string + readonly team_id: string +} + +const Person = defineEntity({ + columns: { id: "STRING", name: "STRING", team_id: "STRING" }, + indexes: [{ columns: ["team_id"], name: "people_team_id_idx" }], + primaryKey: "id", + tableName: "people", +}) + +const dataSource = await createGitDbDataSource({ + entities: [Person], + store: new LocalPlaintextStore({ root: ".gitdb" }), + synchronize: true, +}) +``` + +`synchronize: true` creates missing tables from entity metadata. It does not run +destructive migrations. + +## Repository + +```ts +const people = dataSource.getRepository(Person) + +await people.insert({ id: "p1", name: "Lin", team_id: "storage" }) +await people.insertMany([ + { id: "p2", name: "Ada", team_id: "runtime" }, + { id: "p3", name: "Grace", team_id: "storage" }, +]) + +await people.saveMany([{ id: "p2", name: "Ada Lovelace", team_id: "runtime" }]) + +const storagePeople = await people.find({ where: { team_id: "storage" } }) +const ada = await people.findOne({ where: { id: "p2" } }) +const deleted = await people.delete({ id: "p1" }) +``` + +`insertMany()` and `saveMany()` share one transaction boundary. Prefer them over +repeated single-row `save()` calls when loading batches. + +## Indexes + +Indexes are equality-only and rebuildable. The entity metadata records intent: + +```ts +indexes: [{ columns: ["team_id"], name: "people_team_id_idx" }] +``` + +The runtime maintains primary/equality indexes from committed rows. Simple +repository `find({ where })` calls and safe `SELECT * FROM table WHERE col = +literal` queries use the index path. Stale or missing index snapshot files are +ignored because indexes are derived data. + +## Engine + +```ts +import { GitDbEngine, LocalPlaintextStore } from "@3xhaust/gitdb" + +const engine = await GitDbEngine.open({ + snapshotPolicy: { mode: "interval", mutations: 100 }, + store: new LocalPlaintextStore({ root: ".gitdb" }), +}) + +await engine.execute("CREATE TABLE people (id STRING, name STRING)") +await engine.execute("INSERT INTO people VALUES ('p1', 'Lin')") +const rows = await engine.execute("SELECT * FROM people WHERE id = 'p1'") +``` + +Transactions run on an isolated database copy and persist only after the +callback succeeds: + +```ts +await engine.transaction(async (tx) => { + await tx.execute("INSERT INTO people VALUES ('p2', 'Ada')") + await tx.execute("INSERT INTO people VALUES ('p3', 'Grace')") +}) +``` + +## Compaction + +```ts +const result = await engine.compact() +``` + +Compaction is currently enabled for local plaintext stores. It writes a matching +page snapshot, advances the manifest to an empty `logSegments` list, then +deletes obsolete mutation segments. Reopen still trusts the manifest sequence, +not index files. + +## Stores + +Local plaintext: + +```ts +new LocalPlaintextStore({ root: ".gitdb" }) +``` + +Local encrypted: + +```ts +import { LocalEncryptedStore, createAesGcmCipher } from "@3xhaust/gitdb" + +new LocalEncryptedStore({ + cipher: createAesGcmCipher(process.env.GITDB_KEY ?? ""), + root: ".gitdb", +}) +``` + +GitHub plaintext: + +```ts +import { GitHubPlaintextStore } from "@3xhaust/gitdb" + +new GitHubPlaintextStore({ + branch: "main", + owner: "3x-haust", + prefix: "gitdb/v1", + repo: "my-project-db", + token: process.env.GITDB_GITHUB_TOKEN ?? "", +}) +``` + +GitHub stores batch writes with the Git Database tree, commit, and non-force ref +update flow. GitHub is durable remote storage and audit history; SELECT queries +still run locally. + +## Migration + +Existing `gitdb/v1` repositories remain readable. The next committed write +upgrades manifest metadata to v2, while the path layout remains `gitdb/v1`. +Unreferenced mutation files are ignored unless the manifest lists them. See +[MIGRATION.md](MIGRATION.md) for rollback and storage details. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7fd9779..cb33859 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,55 +1,119 @@ -# Architecture +# GitDB Architecture -GitDB has four layers. +GitDB is a first-party database runtime over repository storage. Applications +use GitDB's own API; query execution stays local; repository storage provides +durable history and audit. -## PostgreSQL Facade +## Runtime Layers -`gitdb serve` starts a PostgreSQL-compatible TCP server. Existing ORMs keep their -normal PostgreSQL provider and point their connection string at GitDB. +1. **First-party API** + - `createGitDbDataSource` + - `defineEntity` + - `GitDbRepository` + - `insert`, `insertMany`, `saveMany`, `find`, `findOne`, `delete` -The facade handles startup, simple queries, prepared parse/bind/execute flows, -row descriptions, data rows, and command completion messages. +2. **SQL engine** + - Local SQL execution with joins, grouping, ordering, and aggregates + - Serialized mutation queue + - Isolated transaction copies + - Rebuildable equality indexes -## SQL Engine +3. **Storage engine contract** + - Manifest read/write + - Mutation append/replay + - Additive batch commit boundary + - Optional lock, snapshot, compaction, and GitHub tree-commit capabilities -The current engine uses `alasql` for advanced SQL execution, including joins, -grouping, ordering, and aggregates. PostgreSQL-flavored SQL is normalized before -execution so common ORM-generated SQL can run. +4. **Repository storage** + - Local plaintext for public, inspectable page snapshots + - Local encrypted for opaque manifest and log files + - GitHub plaintext/encrypted for remote durable history -Unsupported PostgreSQL features return explicit errors instead of falling back -silently. +## Guarantee Matrix -## Storage Engine +| Area | What GitDB guarantees | What it does not claim | +| --- | --- | --- | +| Transactions | Successful mutations advance one manifest sequence boundary. Failed mutations restore the previous committed state. | Cross-process distributed transactions | +| Concurrency | Local stores use a single-writer lock with timeout and stale recovery. Readers reopen committed state. | Multi-host OLTP write coordination | +| Durability | Manifest-listed mutation logs replay on open. Matching visible snapshots can replace replay. | Trusting uncheckpointed snapshot files | +| Snapshots | Plaintext snapshots are page-level JSON files with `snapshot.json` as the checkpoint. | Treating snapshots as the source of truth | +| Indexes | Equality indexes rebuild from committed rows and accelerate safe equality lookups. | Full SQL optimizer or range indexes | +| Compaction | Local plaintext compaction writes checkpoint first, manifest second, deletes old logs last. | Encrypted checkpoint compaction | +| GitHub sync | Remote writes use tree creation, commit creation, and non-force ref update. | SELECT over GitHub or live remote lock service | -GitDB persists schema and data changes as encrypted mutation segments: +## Commit Boundary + +The manifest is the committed boundary. A mutation becomes durable only when the +manifest references its segment. Unreferenced segments may exist after a crash, +but they are not replayed. + +For batch commits, GitDB writes mutation segments first and advances the +manifest last. Stores with native batch support can commit the manifest and log +files together. GitHub stores use the Git Database API to write a tree, create a +commit, and update the branch ref with `force: false`. + +## Recovery + +On open: + +1. Read the manifest. +2. Read a visible snapshot only if the store supports it. +3. Use the snapshot only when its checkpoint sequence equals the manifest + sequence. +4. Otherwise replay manifest-listed mutation segments in order. +5. Rebuild equality indexes from the recovered rows. + +This is why stale page files, stale `indexes.json`, and orphan mutation files do +not corrupt query results. + +## Snapshots And Indexes + +Plaintext snapshots are designed for review and faster reopen: ```text -gitdb/v1/manifest.enc -gitdb/v1/log/.enc +gitdb/v1/ + snapshot.json + people/ + schema.json + pages.json + pages/ + 000000.json + indexes.json ``` -On startup, the engine reads the manifest, decrypts the mutation log, and replays -it into the hot query engine. This avoids GitHub round-trips during query -execution. +`indexes.json` is derived data. It is useful for inspection and future tooling, +but the runtime does not trust it over committed rows. -## GitHub Provider +## Compaction -The GitHub provider uses the Repository Contents API to read and create/update -encrypted segment files. Existing file `sha` values are used for optimistic -concurrency when updating files. +`GitDbEngine.compact()` currently targets local plaintext stores: -GitHub is the durable sync layer. Query execution stays local for acceptable -latency. +1. Write a visible page snapshot at the current manifest sequence. +2. Advance the manifest to an empty `logSegments` list. +3. Delete obsolete mutation segment files. -Public deployments should write encrypted database objects to a branch that is -separate from the application branch. The current release uses `main` for source -code and `data` for encrypted `gitdb/v1` objects. This keeps public storage -opaque while avoiding auto-deploy loops from database writes. +Crash behavior: -## Deployment Boundary +- Before checkpoint: old manifest and old logs remain usable. +- After checkpoint before manifest: old manifest can still reopen safely. +- After manifest before deletion: new manifest reopens from the checkpoint while + old logs are harmless. + +## Concurrency Tier + +GitDB's supported local tier is single-writer, multiple-reader. The local lock +prevents two local writers from committing at the same time. GitHub remote +updates use optimistic non-force ref updates and retry on conflict. This is +bounded concurrency control, not distributed OLTP. + +## GitHub Remote Path + +GitHub is remote durable storage and audit history: + +```text +read ref -> read base commit -> create tree -> create commit -> update ref +``` -`pnpm start` launches the HTTP control plane and the PostgreSQL-compatible TCP -facade in one process. HTTP deployments can verify readiness through `/` or -`/health`. ORM clients still need TCP access to the facade port. When the deploy -target only exposes HTTP ingress, run `gitdb serve` near the ORM process and use -GitHub as the shared encrypted durability layer. +The branch update is non-force by default. A conflict triggers a fresh read and +retry. Query execution still happens in the local runtime; GitHub is not the +SELECT hot path. diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 4681a01..066e556 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -1,71 +1,131 @@ # GitDB Benchmarks -Benchmarks are intentionally split between local engine speed and GitHub sync -speed. GitDB should feel fast because queries run against the local SQL engine; -GitHub is the durable sync layer, not the per-query hot path. +Benchmarks separate local execution from remote durability. Queries and planning +run in the local runtime; GitHub is a durable sync and audit layer, not the +per-query hot path. -## Environment +## Commands -- Date: 2026-06-09 KST -- Machine: Apple Silicon laptop, Node.js 20.19.1 -- Command: `pnpm benchmark` -- GitHub command: `GITDB_BENCH_GITHUB_ROWS=2 pnpm benchmark:github` -- Workload: create `teams` and `people`, insert rows, execute a join, reopen - the store where applicable. +Run the current local benchmark: -## Results +```bash +GITDB_BENCH_ROWS=250 corepack pnpm benchmark:evaluate +``` -| Scenario | Rows | Write ms | Writes/s | Join ms | Reopen ms | +Refresh website JSON and Markdown comparison evidence: + +```bash +GITDB_BENCH_ROWS=250 corepack pnpm benchmark:compare +``` + +Run release gates: + +```bash +GITDB_BENCH_ROWS=250 corepack pnpm benchmark:gate +GITDB_BENCH_ROWS=1000 corepack pnpm benchmark:gate +``` + +The 10k-row suite is available for manual investigation, but it is not a quick +CI gate because single-row transaction scenarios intentionally take time: + +```bash +GITDB_BENCH_ROWS=10000 corepack pnpm benchmark:gate +``` + +Optional live GitHub storage benchmarking remains credential-gated: + +```bash +GITDB_BENCH_GITHUB_ROWS=8 corepack pnpm benchmark:github +``` + +## Current Evidence + +Captured on 2026-06-15 KST with: + +```bash +GITDB_BENCH_ROWS=250 corepack pnpm benchmark:compare +``` + +Runtime metadata: + +| Field | Value | +| --- | --- | +| Node | v24.11.0 | +| Platform | darwin arm64 | +| CPU | Apple M5 | +| Repeat | 1 | +| Warmup | 0 | + +| Scenario | Rows | Write ms | Writes/s | Query ms | Reopen ms | +| --- | ---: | ---: | ---: | ---: | ---: | +| local plaintext transaction batch | 250 | 1438.67 | 173.77 | 5.48 | 66.35 | +| local encrypted transaction batch | 250 | 2173.42 | 115.03 | 0.73 | 41.65 | +| orm save() single transactions | 250 | 12304.65 | 20.32 | 0.31 | 107.08 | +| orm insert() single transactions | 250 | 8435.10 | 29.64 | 0.24 | 53.70 | +| orm insertMany bulk transaction | 250 | 1049.41 | 238.23 | 0.13 | 55.53 | +| orm saveMany bulk transaction | 250 | 2409.24 | 103.77 | 0.08 | 76.86 | +| orm indexed equality lookup | 250 | 920.12 | 271.70 | 0.08 | 45.93 | +| local plaintext reopen checkpoint | 250 | 1142.73 | 218.77 | 0.74 | 36.38 | +| local plaintext compaction | 250 | 105.46 | 2370.68 | 0.67 | 24.41 | + +## Previous Baseline Comparison + +The same-row previous runtime baseline is stored in +`docs/BENCHMARK_BASELINE.json`. Current comparison: + +| Scenario | Previous writes/s | Current writes/s | Change | Write ms change | Query ms change | | --- | ---: | ---: | ---: | ---: | ---: | -| local plaintext visible snapshots | 250 | 1443.90 | 173.14 | 32.97 | 140.05 | -| local encrypted mutation log | 250 | 761.99 | 328.09 | 4.31 | 322.51 | -| postgres facade over local encrypted | 250 | 987.93 | 253.05 | 19.74 | 0.00 | -| github plaintext contents api | 2 | 14157.49 | 0.14 | 6.70 | 1812.62 | - -## Interpretation - -Local writes are already usable for development workloads. The PostgreSQL wire -facade adds modest overhead while preserving normal ORM connectivity. - -GitHub Contents API writes are not acceptable as the hot write path. The current -GitHub plaintext mode writes multiple files per SQL mutation: log segment, -manifest, visible schema, and visible data. During benchmarking, GitHub also -returned transient 500/502 responses under repeated Contents API writes. GitDB -now retries those transient write failures, but the result confirms that direct -per-mutation GitHub writes are a demo path, not the final architecture. - -## Performance Plan - -1. Add a local write buffer. - Return success after local WAL fsync in `fast` mode, then sync GitHub in the - background. - -2. Batch GitHub commits. - Replace per-file Contents API writes with Git Database tree commits, so one - transaction can update log, manifest, indexes, and visible snapshots in a - single Git commit. - -3. Snapshot less often. - Keep mutation logs per write, but refresh visible `table/data.json` on a - timer, row threshold, or explicit `gitdb sync` command. - -4. Write table deltas. - Store public rows as `table/rows/.json` or chunked pages, then - rebuild `data.json` as a dashboard artifact. This avoids rewriting the whole - table for every insert. - -5. Add local indexes. - Maintain primary and secondary indexes in local cache so joins and filters do - not scan every visible row. - -6. Add cold-start manifests. - Include table snapshot versions in `manifest.json` so startup can skip - directory listing and unchanged table reads. - -7. Separate durability modes. - Keep `fast` as local-durable/background-GitHub and add `strong` for blocking - until the GitHub commit lands. - -8. Add benchmark gates. - Track local 1k/10k row writes, joins, reopen time, GitHub 10-row sync, and - GitHub batch sync once tree commits land. +| local plaintext transaction batch | 173.14 | 173.77 | +0.36% | +0.36% | +83.37% | +| local encrypted transaction batch | 328.09 | 115.03 | -64.94% | -185.23% | +83.04% | +| orm indexed equality lookup | 46.49 | 271.70 | +484.44% | +82.89% | +93.88% | + +The headline improvement is the first-party ORM path: indexed lookup plus bulk +write setup now runs at 271.70 writes/s, a 5.84x speedup over the previous ORM +baseline. Local plaintext write throughput is roughly flat with the previous +raw baseline in this run, while query latency is still much lower. Local encrypted +write throughput is currently slower than the older baseline because encryption +still writes per-segment ciphertext; query and reopen latency improved, and +encrypted compaction remains future work. + +## Bulk API Delta + +The single-row APIs remain in the benchmark because they show the cost of +committing every row independently: + +| Path | Writes/s | Compared with save() | +| --- | ---: | ---: | +| `save()` per row | 20.32 | 1.00x | +| `insert()` per row | 29.64 | 1.46x | +| `insertMany()` | 238.23 | 11.73x | +| `saveMany()` | 103.77 | 5.11x | + +For application code, prefer `insertMany()` and `saveMany()` when a workflow can +commit a logical batch as one transaction. + +## How To Read The Numbers + +- `writeMs` is the time spent on the scenario's write or maintenance operation. +- `writesPerSecond` normalizes `rows / writeMs`. +- `Query ms` is a join for engine scenarios and an indexed equality lookup for + ORM/index scenarios. +- `reopenMs` measures a fresh runtime opening the committed store and running + the validation query. +- The benchmark runner emits JSON metadata for Node, platform, CPU, repeat + count, warmup count, and row count. + +## Benchmark Gate + +`benchmark:gate` validates: + +- local plaintext transaction batch +- local encrypted transaction batch +- ORM single-row `save()` +- ORM single-row `insert()` +- ORM `insertMany()` +- ORM `saveMany()` +- indexed equality lookup +- reopen checkpoint +- compaction + +It also validates positive finite numeric fields, conservative latency +thresholds, and repeat metadata when a JSON evidence envelope is provided. diff --git a/docs/BENCHMARK_BASELINE.json b/docs/BENCHMARK_BASELINE.json new file mode 100644 index 0000000..24ab8bf --- /dev/null +++ b/docs/BENCHMARK_BASELINE.json @@ -0,0 +1,30 @@ +{ + "generatedAt": "2026-06-14T01:35:43.313Z", + "label": "previous runtime baseline", + "results": [ + { + "joinMs": 32.97, + "label": "local plaintext visible snapshots", + "reopenMs": 140.05, + "rows": 250, + "writeMs": 1443.9, + "writesPerSecond": 173.14 + }, + { + "joinMs": 4.31, + "label": "local encrypted mutation log", + "reopenMs": 322.51, + "rows": 250, + "writeMs": 761.99, + "writesPerSecond": 328.09 + }, + { + "joinMs": 1.3, + "label": "orm local plaintext indexed lookup", + "reopenMs": 136.29, + "rows": 250, + "writeMs": 5377.34, + "writesPerSecond": 46.49 + } + ] +} diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..db2dc8c --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,140 @@ +# GitDB Migration Guide + +This guide covers the public npm API and durable storage format supported by +GitDB 0.1.x while the runtime is being rebuilt around an explicit storage engine +contract. + +## Public API + +The root package export is the stable consumer surface. Treat anything outside +this list as internal unless a future release documents it here. + +- `createGitDbDataSource` +- `defineEntity` +- `GitDbDataSource` +- `GitDbRepository` +- `GitDbEntityIndexDefinition` +- `GitDbInsertResult` +- `GitDbSaveManyResult` +- `GitDbEngine` +- `GitDbDurabilityMode` +- `GitDbEngineOptions` +- `LocalPlaintextStore` +- `LocalEncryptedStore` +- `GitHubEncryptedStore` +- `GitDbStore` +- `GitDbStoreV2` +- `GitDbCommitBatch` +- `GitDbStoreCapabilities` +- `GitDbCompactionResult` +- `SnapshotPolicy` +- `commitBatchThroughStore` + +GitDB is ESM-only. The npm root export provides `import` and `types`; it does +not provide a CommonJS `require` condition. + +## Storage Format + +The current on-disk and GitHub path layout remains `gitdb/v1/`. + +Plaintext stores use: + +```text +gitdb/v1/ + manifest.json + snapshot.json + people/ + schema.json + pages.json + pages/ + 000000.json + indexes.json + log/ + 00000000000000000001.json +``` + +Encrypted stores use: + +```text +gitdb/v1/ + manifest.enc + log/ + 00000000000000000001.enc +``` + +`manifest v1` remains readable. Its committed boundary is the manifest sequence +and `logSegments` list. Visible snapshots are still an optimization and are used +only when their checkpoint sequence matches the manifest. + +Page snapshots replace old whole-table `data.json` snapshots. New plaintext +checkpoints write `pages/*.json`, `pages.json`, `indexes.json`, and +`snapshot.json`. + +## Store Contract V2 + +`store contract v2` is additive. Existing v1 stores that implement +`readManifest`, `writeManifest`, `appendMutation`, and `readMutations` can be +used through `commitBatchThroughStore`. The compatibility path appends mutation +segments first, then advances the manifest boundary. + +New stores should expose `GitDbStoreCapabilities` conservatively. A capability +flag must remain `false` until that store actually owns the guarantee. For +example, local atomic writes, locks, compaction, and GitHub tree commits are not +claimed by the v2 compatibility adapter. + +Current local plaintext capabilities include atomic writes, locks, visible +snapshots, and compaction. Local encrypted stores keep compaction disabled until +encrypted checkpoint restore is implemented. GitHub stores claim tree commits +because they use Git tree, commit, and non-force ref update operations. + +## Durability Modes + +`GitDbDurabilityMode` accepts `local`, `sync`, and `background`. `sync` is the +default and preserves the current behavior: the configured store commits before +the transaction returns. `local` is for local-durable transaction paths. +`background` reserves the local-commit-plus-remote-sync shape; until the remote +queue task lands, it must not be treated as remote durability. + +## Upgrade Behavior + +Opening an existing `gitdb/v1/` repository does not rewrite files. GitDB reads +the current manifest and replays listed mutation segments. The path layout stays +`gitdb/v1/`; after the next successful committed write, the manifest itself is +advanced to v2 metadata so future recovery can distinguish upgraded stores. + +Equality indexes are rebuildable. If `indexes.json` is missing, stale, or +manually edited, GitDB rebuilds runtime indexes from committed rows instead of +trusting the file. + +If a v2 batch is committed through a v1 store, rollback behavior is the same as +the previous manifest-gated model: unreferenced mutation segments may exist, but +they are not replayed unless the manifest references them. A stale +compare-and-swap guard rejects the commit before new mutations are appended. + +## Rollback + +Before testing an upgrade, create a filesystem backup of the store directory: + +```sh +cp -R gitdb "gitdb.backup.$(date +%Y%m%d%H%M%S)" +``` + +To rollback a failed upgrade attempt, keep or restore the last known-good +manifest file and remove any unreferenced log files only after confirming they +are not listed in `logSegments`. Do not edit encrypted payloads by hand; restore +the previous encrypted manifest and segment files as a set. + +## Compaction Migration + +`GitDbEngine.compact()` is additive. It does not change the path prefix and does +not delete logs until after a matching page snapshot and advanced manifest are +durable. After successful local plaintext compaction, `manifest.json` has an +empty `logSegments` list and old segment files have been removed. + +Rollback from a compaction crash: + +- If `manifest.json` still lists old segments, keep those segment files. +- If `manifest.json` has an empty `logSegments` list, keep `snapshot.json` and + the matching page files. +- If both manifest and snapshot are damaged, restore the last filesystem or Git + backup as a set. diff --git a/docs/README.ko.md b/docs/README.ko.md index f3bf955..065ba48 100644 --- a/docs/README.ko.md +++ b/docs/README.ko.md @@ -1,105 +1,72 @@ # GitDB -[English](../README.md) | 한국어 +[English](../README.md) | 한국어 | [Website](https://3x-haust.github.io/gitdb/) -GitDB는 GitHub repository 하나를 프로젝트 전용 데이터베이스처럼 쓰게 해주는 -GitHub-native database입니다. - -애플리케이션은 GitDB가 여는 PostgreSQL 호환 로컬 TCP endpoint에 접속합니다. -Prisma나 `pg` 같은 기존 PostgreSQL 클라이언트는 평범한 -`postgresql://127.0.0.1:7432/main` 주소로 SQL을 보내고, GitDB는 그 SQL을 자체 -엔진에서 실행한 뒤 전용 GitHub repository에 데이터베이스 상태를 저장합니다. +GitDB는 프로젝트 데이터를 위한 GitHub 기반 데이터베이스 런타임입니다. 쿼리와 +쓰기의 hot path는 로컬 엔진에서 처리하고, GitHub 저장소는 durable storage와 +audit trail로 사용합니다. ```text -Express / Prisma / pg - | - | postgresql://127.0.0.1:7432/main - v -GitDB PostgreSQL facade - | - | in-memory SQL engine + manifest/log replay - v -GitHub repository +Application code + -> GitDB DataSource / Repository + -> Local SQL engine + transaction queue + -> Manifest, mutation log, visible snapshots + -> GitHub repository for durable history ``` -GitDB는 SQLite를 GitHub에 올리는 도구가 아닙니다. `.db` 파일을 업로드하지 -않습니다. GitHub repository 자체가 GitDB의 durable database store입니다. +GitDB는 데이터베이스 파일 하나를 GitHub에 올리는 구조가 아닙니다. 핵심은 +storage engine, transaction boundary, replay 가능한 mutation log, snapshot +모델, 그리고 그 위에 직접 붙는 first-party API입니다. ## 왜 GitDB인가 -GitHub에는 이미 commit, pull request, history, branch, review, public/private -repo, access control이 있습니다. GitDB는 이 GitHub primitive를 프로젝트 데이터에 -적용합니다. - -GitDB가 잘 맞는 경우: - -- 프로젝트마다 전용 데이터베이스 repo를 두고 싶을 때, 예: `my-app-db` -- Prisma, TypeORM, Drizzle, Kysely provider를 직접 만들고 싶지 않을 때 -- public demo 데이터를 GitHub 웹 UI에서 바로 보고 수정하고 싶을 때 -- public/private repo에 데이터를 암호화해서 저장하고 싶을 때 -- agent memory, demo, content tool, config tool, 저빈도 앱 데이터를 commit - history로 남기고 싶을 때 +다음 상황에 적합합니다: -GitDB가 맞지 않는 경우: +- 프로젝트마다 별도 database repository를 두고 싶을 때 +- 매 쿼리마다 네트워크 왕복을 만들지 않고 로컬에서 실행하고 싶을 때 +- 패키지에 포함된 TypeORM 스타일 `DataSource`와 repository API가 필요할 때 +- 의도적인 public demo에서 table snapshot을 GitHub에서 바로 보고 싶을 때 +- private data를 encrypted manifest와 mutation log로 저장하고 싶을 때 +- agent memory, demo, content tool, config tool처럼 감사 가능한 기록이 필요한 저빈도 데이터 -- 고빈도 OLTP -- 낮은 지연시간의 다중 writer transaction -- 오늘 당장 완전한 PostgreSQL 호환성이 필요한 서비스 +GitDB는 아직 실험 프로젝트입니다. 높은 처리량의 OLTP, 짧은 지연 시간의 다중 +writer, range-heavy analytics, multi-node coordination이 필요한 워크로드에는 +아직 적합하지 않습니다. -## 기능 +## 현재 표면 | 영역 | 현재 동작 | | --- | --- | -| ORM 접근 | PostgreSQL 스타일 로컬 endpoint를 열어 기존 PostgreSQL client가 접속 | -| SQL | `CREATE TABLE`, `INSERT`, `DELETE`, `SELECT`, join, group, order, aggregate, 일반적인 raw query 흐름 | -| GitHub 저장소 | 데이터베이스마다 전용 repo 사용, 권한이 있으면 최초 write 시 repo 생성 | -| Public plaintext mode | `table/schema.json`, `table/data.json`을 GitHub에서 직접 확인/수정 | -| Encrypted mode | AES-256-GCM으로 암호화된 manifest/log 저장 | -| Local mode | GitHub 변수 없이 로컬 디렉터리에 저장 | -| Example app | Express + Prisma API가 PostgreSQL facade를 통해 GitDB 사용 | -| Benchmark | local, facade, GitHub Contents API 벤치마크 명령 포함 | +| App API | `createGitDbDataSource`, `defineEntity`, typed repository | +| SQL engine | `CREATE TABLE`, `INSERT`, `DELETE`, `SELECT`, join, grouping, ordering, aggregate | +| Storage | local encrypted, local plaintext, GitHub encrypted, GitHub plaintext | +| Durability | manifest-gated mutation log replay와 visible snapshot checkpoint | +| Index | committed row에서 재빌드되는 equality index | +| Compaction | local plaintext page snapshot 뒤 log segment 삭제 | +| Concurrency | local single-writer, multiple readers, stale lock recovery, distributed OLTP 아님 | +| CLI | `gitdb keygen`, `gitdb query`, `gitdb check` | +| Example | `examples/api-local`, `examples/api-encrypted`, `examples/api-encrypted-reopen` | +| Package | npm exports, bin, pack dry-run, publish dry-run 설정 | -## GitHub에 저장되는 구조 +## 저장소 구조 -plaintext public mode에서는 내부 mutation log와 사람이 보기 쉬운 table snapshot을 -같이 저장합니다. +Plaintext mode는 내부 상태와 사람이 읽을 수 있는 snapshot을 함께 씁니다: ```text gitdb/v1/ manifest.json + snapshot.json people/ schema.json - data.json - teams/ - schema.json - data.json + pages.json + pages/ + 000000.json + indexes.json log/ 00000000000000000001.json ``` -`schema.json`에는 schema만 들어갑니다. - -```json -{ - "name": "people", - "columns": ["id", "name", "team_id"] -} -``` - -`data.json`에는 row만 들어갑니다. - -```json -[ - { "id": "p1", "name": "Lin", "team_id": "t1" }, - { "id": "p2", "name": "Ada", "team_id": "t2" } -] -``` - -그래서 public database repo는 가벼운 Firebase 콘솔처럼 확인할 수 있습니다. -GitHub 웹에서 `data.json`을 수정하고 commit하면, 다음 GitDB open 시 visible table -snapshot에서 데이터를 복원합니다. - -encrypted mode에서는 opaque file만 저장합니다. +Encrypted mode는 opaque 파일을 씁니다: ```text gitdb/v1/ @@ -110,278 +77,193 @@ gitdb/v1/ ## 빠른 시작 -설치와 빌드: - ```bash -pnpm install -pnpm build +corepack pnpm install +corepack pnpm build ``` -encrypted local storage로 PostgreSQL facade 실행: +First-party repository API: -```bash -export GITDB_KEY="$(node dist/src/cli/main.js keygen)" -pnpm start:facade +```ts +import { LocalPlaintextStore, createGitDbDataSource, defineEntity } from "@3xhaust/gitdb" + +type Person = { + readonly id: string + readonly name: string + readonly team_id: string +} + +const PersonEntity = defineEntity({ + columns: { id: "STRING", name: "STRING", team_id: "STRING" }, + indexes: [{ columns: ["team_id"], name: "people_team_id_idx" }], + primaryKey: "id", + tableName: "people", +}) + +const dataSource = await createGitDbDataSource({ + entities: [PersonEntity], + store: new LocalPlaintextStore({ root: ".gitdb" }), + synchronize: true, +}) + +const people = dataSource.getRepository(PersonEntity) +await people.save({ id: "p1", name: "Lin", team_id: "storage" }) +const storagePeople = await people.find({ where: { team_id: "storage" } }) ``` -`psql`, `pg`, Prisma 등 PostgreSQL client로 접속: +Secondary index는 `defineEntity`의 equality-only metadata로 선언합니다. 런타임은 +committed row에서 equality index를 재빌드하고, 안전한 `find({ where })`와 단순 +`SELECT * ... WHERE` 조회에 사용합니다. `indexes.json`이 stale이어도 query +result는 committed row 기준으로 재빌드됩니다. + +예제 실행: ```bash -psql postgresql://127.0.0.1:7432/main +corepack pnpm example ``` -예시 SQL: +예제는 패키지를 빌드한 뒤 plaintext API 예제와 암호화 API 예제 2개를 차례로 +실행합니다. 각 예제는 JSON summary를 출력하며, 암호화 reopen 예제는 잘못된 키 +거부와 plaintext 누출 없음까지 확인합니다. -```sql -CREATE TABLE teams (id STRING, name STRING); -CREATE TABLE people (id STRING, name STRING, team_id STRING); +## CLI -INSERT INTO teams VALUES ('t1', 'Storage'); -INSERT INTO people VALUES ('p1', 'Lin', 't1'); +Encryption key 생성: -SELECT people.name, teams.name AS team -FROM people -JOIN teams ON people.team_id = teams.id; +```bash +node dist/src/cli/main.js keygen ``` -## Express + Prisma 예제 - -예제는 실제 API 서비스 형태입니다. Express가 HTTP route를 제공하고, Prisma는 -PostgreSQL facade를 통해 GitDB에 접속하며, GitDB는 example `.env`에 설정된 로컬 -디렉터리 또는 GitHub database repo에 저장합니다. +Store 확인: ```bash -cp examples/express-prisma/.env.example examples/express-prisma/.env -pnpm example +GITDB_ENCRYPTION=off GITDB_ROOT=.gitdb node dist/src/cli/main.js check ``` -다른 터미널에서: +SQL 한 문장 실행: ```bash -curl http://127.0.0.1:3090/health -curl -X POST http://127.0.0.1:3090/seed -curl http://127.0.0.1:3090/people +GITDB_ENCRYPTION=off GITDB_ROOT=.gitdb \ + node dist/src/cli/main.js query "CREATE TABLE people (id STRING, name STRING)" ``` -example은 기본적으로 plaintext mode입니다. +## 환경 변수 + +Local plaintext mode: ```env GITDB_ENCRYPTION=off -GITDB_ROOT=.gitdb-example-public -GITDB_GITHUB_OWNER=3x-haust -GITDB_GITHUB_REPO=gitdb-example-db -GITDB_GITHUB_BRANCH=main -GITDB_GITHUB_PREFIX=gitdb/v1 -GITDB_GITHUB_TOKEN= -API_PORT=3090 +GITDB_ROOT=.gitdb ``` -`GITDB_GITHUB_TOKEN`을 비워두면 로컬 파일로 테스트합니다. GitHub에 실제로 쓰고 -싶으면 dedicated database repo에 Contents read/write 권한이 있는 token을 넣으면 -됩니다. repo가 아직 없으면 owner 아래 repo를 생성할 권한도 필요합니다. - -## 환경 변수 모델 - -루트 `.env`는 GitDB facade process용입니다. +Local encrypted mode: ```env GITDB_ENCRYPTION=on GITDB_KEY=generated-by-gitdb-keygen GITDB_ROOT=.gitdb -GITDB_HOST=0.0.0.0 -GITDB_PORT=7432 ``` -example은 각자 별도의 `.env`를 둡니다. app 설정과 database-repository 설정이 -package root에 섞이지 않게 하기 위해서입니다. - -### 암호화 - -`GITDB_KEY`는 GitDB가 생성한 base64url 32-byte key여야 합니다. +GitHub 저장소를 쓸 때는 아래 값을 추가합니다: -```bash -node dist/src/cli/main.js keygen -``` - -이 값은 Git에 올리면 안 됩니다. key가 바뀌면 기존 encrypted data를 복호화할 수 -없습니다. - -`GITDB_ENCRYPTION=off`는 table name, column, row를 GitHub에서 공개적으로 보고 -싶은 demo에서만 사용하세요. - -### GitHub storage - -facade를 실행하는 process에 아래 변수를 넣습니다. - -```bash -export GITDB_GITHUB_OWNER="3x-haust" -export GITDB_GITHUB_REPO="my-project-db" -export GITDB_GITHUB_BRANCH="main" -export GITDB_GITHUB_PREFIX="gitdb/v1" -export GITDB_GITHUB_TOKEN="github_pat_... or ghp_..." -gitdb serve -``` - -권장 구조: - -- source repo: `my-project` -- database repo: `my-project-db` -- 공개 demo data: `GITDB_ENCRYPTION=off` -- 실제 public/private data: `GITDB_ENCRYPTION=on` - -## Runtime과 신뢰 모델 - -`gitdb serve`와 hosted GitDB endpoint는 같은 종류의 runtime입니다. 둘 다 -PostgreSQL facade, SQL engine, GitHub sync를 담당합니다. 중요한 차이는 그 runtime이 -어디에서 실행되느냐입니다. - -| Mode | Runtime 위치 | 누가 복호화 가능한가 | 적합한 경우 | -| --- | --- | --- | --- | -| Self-hosted encrypted | 유저 앱 서버, VPS, 로컬, private infra | `GITDB_KEY`를 가진 유저 환경만 | 실제 앱 데이터, public encrypted repo, private repo | -| Hosted plaintext | `gitdb.3xhaust.dev` 같은 GitDB hosted runtime | 어차피 GitHub repo를 보는 모두 | public demo, public dataset, inspectable example | -| Hosted encrypted | GitDB hosted runtime | hosted runtime이 key를 받아야 함 | 편의성을 위한 managed mode, zero-knowledge 아님 | - -암호화된 데이터를 “내 서비스만 복호화 가능”하게 만들고 싶다면 GitDB를 직접 -호스트해야 합니다. - -```text -Your App -> your gitdb serve -> encrypted GitHub repo +```env +GITDB_GITHUB_OWNER=3x-haust +GITDB_GITHUB_REPO=my-project-db +GITDB_GITHUB_BRANCH=main +GITDB_GITHUB_PREFIX=gitdb/v1 +GITDB_GITHUB_TOKEN=github_token_with_contents_write_access ``` -`GITDB_KEY`를 hosted runtime에 보내는 순간, 그 runtime은 query 실행을 위해 -plaintext를 처리할 수 있습니다. 이건 의도적으로 key를 맡기는 managed mode이지, -운영자도 볼 수 없는 구조가 아닙니다. +로컬 개발만 할 때는 `GITDB_GITHUB_TOKEN`을 비워둡니다. `GITDB_ENCRYPTION=off`는 +table 이름, column, row가 공개되어도 되는 demo에서만 사용하세요. ## 아키텍처 -GitDB는 세 층으로 나뉩니다. +GitDB는 네 계층으로 나뉩니다: -1. PostgreSQL-compatible facade - - 로컬 TCP endpoint를 엽니다. - - 기존 client가 평범한 PostgreSQL connection string으로 접속합니다. - - ORM별 provider/driver를 만들지 않아도 됩니다. +1. First-party API + - `DataSource`, `Repository`, `save`, `find`, `findOne`, `delete`, raw + `query`, transaction access를 제공합니다. + - 새 앱이 GitDB runtime 표면에 직접 붙도록 합니다. 2. SQL engine - - schema, mutation execution, query execution, join, grouping, result row를 - 처리합니다. - - Node.js client와 ORM raw-query 흐름에서 자주 나오는 PostgreSQL subset을 - 우선 지원합니다. + - Schema, mutation, query, join, grouping, ordering, result row를 처리합니다. + - Local mutation을 persistence 전에 직렬화합니다. 3. Storage provider - - 개발/테스트용 local encrypted store - - visible snapshot용 local plaintext store - - remote durability용 GitHub encrypted/plaintext store + - Local encrypted store + - Local plaintext store + - GitHub encrypted/plaintext store + +4. Audit and recovery model + - Manifest가 committed sequence를 기록합니다. + - Mutation log는 open 시 replay 가능합니다. + - Visible snapshot은 plaintext reopen path를 빠르게 합니다. 자세한 내용은 [ARCHITECTURE.md](ARCHITECTURE.md)를 참고하세요. ## 벤치마크 -로컬 벤치마크: +Local benchmark: ```bash -pnpm benchmark +corepack pnpm benchmark ``` -GitHub write 벤치마크: +Website benchmark evidence 갱신: ```bash -GITDB_BENCH_GITHUB_ROWS=2 pnpm benchmark:github +GITDB_BENCH_ROWS=250 corepack pnpm benchmark:site ``` -최근 측정 결과: +이전 local run과 비교: + +```bash +GITDB_BENCH_ROWS=250 corepack pnpm benchmark:compare +``` + +최근 측정: | Scenario | Rows | Write ms | Writes/s | Join ms | Reopen ms | | --- | ---: | ---: | ---: | ---: | ---: | -| local plaintext visible snapshots | 250 | 1443.90 | 173.14 | 32.97 | 140.05 | -| local encrypted mutation log | 250 | 761.99 | 328.09 | 4.31 | 322.51 | -| postgres facade over local encrypted | 250 | 987.93 | 253.05 | 19.74 | 0.00 | -| github plaintext contents api | 2 | 14157.49 | 0.14 | 6.70 | 1812.62 | +| local plaintext throttled visible snapshots | 25 | 646.19 | 38.69 | 10.26 | 20.38 | +| local encrypted mutation log | 25 | 650.07 | 38.46 | 2.06 | 24.26 | +| orm local plaintext indexed lookup | 25 | 1012.42 | 24.69 | 1.47 | 32.68 | +| local plaintext compaction | 25 | 96.23 | 259.80 | 2.58 | 11.56 | -해석은 분명합니다. local execution은 실험/저빈도 workload에는 쓸 만합니다. 하지만 -mutation마다 GitHub Contents API를 직접 호출하는 방식은 hot path가 될 수 -없습니다. 현재 구현에서 live query는 manifest, mutation log, visible snapshot에서 -복원된 in-memory SQL engine에서 실행됩니다. WAL, index, batched Git commit은 현재 -보장사항이 아니라 다음 storage-engine 작업입니다. +해석: raw local execution은 demo와 저빈도 프로젝트 데이터에 충분히 빠릅니다. +Repository bulk write는 `insertMany()`나 `saveMany()`를 쓰고, query 측정에는 +equality index lookup이 포함됩니다. Compaction은 checkpoint 뒤 old log를 삭제해 +reopen path를 짧게 만듭니다. 자세한 내용은 [BENCHMARKS.md](BENCHMARKS.md)를 참고하세요. -## 성능 개선 로드맵 - -다음 성능 개선은 facade보다 storage 구조가 핵심입니다. - -- Local WAL first: `fast` mode에서는 local durable write 후 성공 반환 -- Batched Git commits: 반복 Contents API write를 Git Database tree commit으로 교체 -- Snapshot throttling: 매 mutation마다 `data.json`을 다시 쓰지 않기 -- Chunked table pages: row 하나 때문에 table 전체를 다시 쓰지 않기 -- Local primary/secondary index: join/filter가 GitHub hot path를 타지 않게 하기 -- Manifest versions: cold start에서 unchanged table read 생략 -- Strong mode: 필요할 때만 GitHub commit 완료까지 block - -## 보안 모델 - -encrypted mode에서는 manifest와 mutation log를 AES-256-GCM으로 암호화합니다. -복호화 key는 repository에 저장하지 않습니다. - -public GitHub repository에서는 암호화해도 아래 metadata는 드러날 수 있습니다. - -- commit 시간 -- file 개수 -- 대략적인 file 크기 -- write 빈도 - -완화 방법은 batch, padding, compaction, 향후 opaque path storage입니다. - ## 현재 한계 -- SQL 지원 범위는 현재 GitDB engine이 실행하는 subset입니다. -- PostgreSQL catalog emulation은 아직 완전하지 않습니다. -- multi-process writer는 GitHub state로 감지하지만, 고동시성 OLTP database는 - 아닙니다. -- GitHub Contents API mode는 demo/correctness test용이지 production write - throughput용이 아닙니다. -- public plaintext mode는 말 그대로 공개 모드입니다. +- SQL 지원 범위는 현재 실행 가능한 subset으로 제한됩니다. +- Single-row `save()`는 bulk path가 아닙니다. Repository batch write는 + `insertMany()`나 `saveMany()`를 사용합니다. +- Local multi-process write는 single-writer, multiple readers, stale lock + recovery를 지원합니다. Distributed OLTP는 아닙니다. +- GitHub tree commit은 durable audit sync용이지 SELECT hot path나 distributed OLTP가 아닙니다. +- Local encrypted compaction은 encrypted checkpoint restore가 붙기 전까지 비활성화되어 있습니다. +- Public plaintext mode는 private mode가 아닙니다. -지원하지 않는 SQL은 조용히 틀린 결과를 내지 않고 명시적으로 실패해야 합니다. +지원하지 않는 SQL은 조용히 성공한 척하지 않고 명시적으로 실패해야 합니다. -## 명령어 +## Commands ```bash -pnpm check -pnpm test -pnpm build -pnpm benchmark -pnpm start:facade -pnpm example +corepack pnpm check +corepack pnpm test +corepack pnpm build +corepack pnpm benchmark +corepack pnpm benchmark:evaluate +corepack pnpm pack:dry-run +corepack pnpm publish:dry-run +corepack pnpm example ``` -## 배포 - -배포 서비스는 NestJS HTTP control plane과 PostgreSQL-compatible facade를 같은 -process에서 실행합니다. - -```bash -docker build -t gitdb . -docker run -p 3000:3000 -p 7432:7432 --env-file .env gitdb -``` - -`pnpm start`는 HTTP control plane을 실행합니다. `pnpm start:facade`는 local ORM -test용 TCP facade만 실행합니다. - -현재 public HTTP control plane: - -```text -https://gitdb.3xhaust.dev/health -``` - -`gitdb.3xhaust.dev`는 hosted GitDB runtime/control-plane instance로 보면 됩니다. -public plaintext workflow, demo, setup flow, 향후 managed mode에는 유용합니다. -하지만 encrypted data를 내 서비스만 복호화하게 만들고 싶다면 직접 `gitdb serve`를 -실행하고 `GITDB_KEY`를 그 환경에만 둬야 합니다. - -HTTP deployment가 외부 ORM client에 TCP facade를 자동으로 노출하는 것은 아닙니다. -remote ORM access가 필요하면 application 근처에서 `gitdb serve`를 실행하거나 TCP -port `7432`를 노출하는 배포 환경을 사용해야 합니다. - -## 라이선스 +## License MIT diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..a5d4295 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,161 @@ +# Release Handoff + +This project separates local release validation from credential-gated live +operations. Local dry-runs are safe to run on any contributor machine. Live +GitHub benchmarks, npm publishing, GitHub releases, and Pages deployment require +explicit credentials and a human-owned release decision. + +## Local Dry-Run + +Run these commands before asking for a live release: + +```bash +corepack pnpm check +corepack pnpm test +corepack pnpm build +corepack pnpm example +GITDB_BENCH_ROWS=250 corepack pnpm benchmark:gate +GITDB_BENCH_ROWS=1000 corepack pnpm benchmark:gate +GITDB_BENCH_ROWS=250 corepack pnpm benchmark:compare +corepack pnpm pack:dry-run +corepack pnpm publish:dry-run +``` + +`pack:dry-run` builds the package and prints the npm tarball payload without +uploading it. `publish:dry-run` uses `npm publish --dry-run --access public`, so +it validates metadata without publishing to the registry. + +## Credential-Gated Live Steps + +These steps are intentionally not part of default CI and must not run from a +developer shell without a release owner: + +1. Live GitHub storage benchmark with `corepack pnpm benchmark:github`. +2. GitHub branch push and pull request creation. +3. npm publish through trusted publishing. +4. GitHub release creation and GitHub Pages deployment. +5. Post-release smoke checks against npm and the public website. + +Each live step should write evidence under `evidence/rebuild/` or a release +artifact before the release owner proceeds to the next step. + +## Release Workflow + +`.github/workflows/release.yml` is the package release entry point. It supports +two paths: + +- Manual `workflow_dispatch`, which always runs package dry-run validation. +- Tag pushes matching `v*`, which run the same validation before any publish + job can start. + +The `dry-run` job runs `corepack pnpm check`, `corepack pnpm test`, `corepack +pnpm build`, a 25-row benchmark gate, `pack:dry-run`, and `publish:dry-run`. +It has no npm token dependency. + +The `publish` job runs only after `dry-run` succeeds on a `v*` tag and either: + +- the workflow was started from a `v*` tag, or +- a release owner manually started `workflow_dispatch` on a `v*` tag with + `publish=true`. + +The publish job is bound to the GitHub environment `npm-publish`; configure that +environment with required reviewers before using it for a live release. The job +uses GitHub Actions OIDC (`id-token: write`) and does not read `NPM_TOKEN`. + +## Live GitHub Benchmark + +`benchmark:github` writes real Git tree commits. It is opt-in and fails before a +remote write when required credentials are missing. + +Required environment: + +```env +GITDB_GITHUB_TOKEN=github_token_with_contents_write_access +GITDB_GITHUB_OWNER=3x-haust +GITDB_GITHUB_REPO=gitdb-benchmark-sandbox +GITDB_GITHUB_BRANCH=gitdb-bench +GITDB_BENCH_GITHUB_PREFIX=gitdb/bench-YYYYMMDD +GITDB_BENCH_GITHUB_ROWS=8 +``` + +Use an isolated repository or an isolated branch/prefix pair. The token should +be a fine-grained GitHub token or GitHub App token with repository Contents read +and write permission only for the benchmark repository. Do not run live +benchmarks against the product source branch. + +Cleanup policy: + +- Keep benchmark commits only when they are release evidence. +- Delete temporary prefixes after the release handoff is complete. +- Prefer a short-lived branch such as `gitdb-bench`. +- Rotate or revoke temporary tokens after use. + +## npm Trusted Publishing + +The package metadata is configured for public npm publishing with provenance: + +```json +{ + "publishConfig": { + "access": "public", + "provenance": true + } +} +``` + +The preferred live path is npm trusted publishing from GitHub Actions, using +OpenID Connect rather than a long-lived `NPM_TOKEN`. Configure the npm package +trusted publisher to point at this repository and the release workflow: + +```text +Organization or user: 3x-haust +Repository: gitdb +Workflow filename: release.yml +Environment name: npm-publish +Allowed actions: npm publish +``` + +npm trusted publishing currently requires npm CLI 11.5.1 or later and Node +22.14.0 or later on a GitHub-hosted runner. The release workflow uses Node 24 +and disables package-manager caching in the release build. The workflow must +include: + +```yaml +permissions: + contents: read + id-token: write +``` + +The release job should run `corepack pnpm check`, `corepack pnpm test`, +`corepack pnpm build`, `corepack pnpm pack:dry-run`, and then `npm publish +--access public` only from a `v*` tag-gated release path. +Trusted publishing automatically uses OIDC for authentication and provenance. +Official reference: https://docs.npmjs.com/trusted-publishers/ + +## GitHub Release + +Create a GitHub release only after local verification, benchmark comparison, +package dry-run, and trusted publisher configuration are complete. Release +notes should start from `docs/RELEASE_NOTES.md` and include: + +- Architecture changes in the runtime/storage engine. +- Performance deltas from `docs/BENCHMARKS.md` and `site/benchmark.json`. +- Migration notes from `docs/MIGRATION.md`. +- Known limits and credential-gated follow-up work. + +Use the Pull Request Draft and Live Handoff Checklist in +`docs/RELEASE_NOTES.md` for PR creation, npm publish, Pages deployment, and +post-release smoke validation. + +## Post-Release Validation + +After publish, validate: + +```bash +npm view @3xhaust/gitdb version +npm view @3xhaust/gitdb dist.tarball +npm pack @3xhaust/gitdb --dry-run +``` + +Then open the public site and verify the benchmark table, guarantee matrix, +documentation links, and npm install command render correctly. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md new file mode 100644 index 0000000..154a6d2 --- /dev/null +++ b/docs/RELEASE_NOTES.md @@ -0,0 +1,154 @@ +# GitDB Runtime Rebuild Release Notes + +## Pull Request Draft + +Title: + +```text +feat: rebuild GitDB as a first-party database runtime +``` + +Summary: + +- Removes the old adapter positioning and documents GitDB as a storage-engine + runtime over repository-backed durability. +- Adds store contract v2, manifest-gated batch commits, local writer locks, + GitHub tree-commit sync, page snapshots, equality indexes, compaction, and a + first-party repository API with bulk write methods. +- Publishes benchmark evidence, a rebuilt website, npm dry-run scripts, and a + trusted publishing workflow that keeps live npm publish credential-gated. + +Reviewer focus: + +- Storage commit boundary: manifest sequence remains the source of truth. +- Recovery: stale snapshots and indexes are ignored or rebuilt from committed + logs. +- Positioning: GitHub is durable audit storage, not the SELECT hot path. +- Release safety: live npm publish requires the `npm-publish` environment and + npm trusted publisher setup. + +## Architecture + +GitDB is now framed as "GitHub Repo + DB Runtime" instead of a wire-protocol +shell over files. The runtime owns: + +- serialized transactions and manifest-gated visibility; +- local atomic writes, stale lock recovery, and store capability metadata; +- replayable mutation logs and checkpointed visible snapshots; +- rebuildable primary and secondary equality indexes; +- local plaintext compaction with checkpoint-first ordering; +- GitHub Git Database tree, commit, and non-force ref update sync; +- typed `DataSource` and repository APIs for app code. + +The public contract is documented in `docs/ENGINE_CONTRACT.md`, +`docs/ARCHITECTURE.md`, `docs/API.md`, and `docs/MIGRATION.md`. + +## Performance + +Benchmark evidence was refreshed on 2026-06-15 KST with +`GITDB_BENCH_ROWS=250 corepack pnpm benchmark:compare`. + +| Scenario | Writes/s | Query ms | Reopen ms | +| --- | ---: | ---: | ---: | +| local plaintext transaction batch | 173.77 | 5.48 | 66.35 | +| local encrypted transaction batch | 115.03 | 0.73 | 41.65 | +| `save()` single transactions | 20.32 | 0.31 | 107.08 | +| `insertMany()` bulk transaction | 238.23 | 0.13 | 55.53 | +| indexed equality lookup | 271.70 | 0.08 | 45.93 | +| local plaintext compaction | 2370.68 | 0.67 | 24.41 | + +Compared with the previous same-row baseline: + +- indexed ORM lookup improved from 46.49 writes/s to 271.70 writes/s + (`+484.44%`, 5.84x); +- `insertMany()` is 11.73x faster than per-row `save()`; +- `saveMany()` is 5.11x faster than per-row `save()`; +- local plaintext write throughput is roughly flat in this run, but query + latency improved sharply; +- encrypted write throughput is slower while encrypted compaction remains a + future storage-engine task. + +Full data lives in `docs/BENCHMARKS.md`, `site/benchmark.json`, and +`site/benchmark.md`. + +## Migration + +Existing users should migrate toward the first-party runtime surface: + +- use `createGitDbDataSource`, `defineEntity`, and repositories from the root + package export; +- prefer `insertMany()` or `saveMany()` for logical batches; +- declare equality indexes in entity metadata when using frequent exact-match + lookups; +- treat visible snapshots, page files, and index files as derived data; +- use `docs/MIGRATION.md` for manifest v1 to store contract v2 behavior. + +Old external adapter positioning is intentionally not part of this release. + +## Known limits + +- GitDB remains experimental and is not a mature distributed OLTP database. +- Local writes are single-writer with stale lock recovery. +- GitHub sync is an audit/durability path, not a per-query network path. +- SQL support is intentionally bounded to the current runtime subset. +- Encrypted compaction is not enabled until encrypted checkpoint restore lands. +- Live GitHub benchmarks, npm publish, GitHub releases, and Pages release + validation are credential-gated. + +## Verification + +Local verification commands for this rebuild: + +```bash +corepack pnpm check +corepack pnpm test +corepack pnpm build +corepack pnpm example +GITDB_BENCH_ROWS=250 corepack pnpm benchmark:gate +GITDB_BENCH_ROWS=1000 corepack pnpm benchmark:gate +GITDB_BENCH_ROWS=250 corepack pnpm benchmark:compare +corepack pnpm pack:dry-run +corepack pnpm publish:dry-run +``` + +Website QA evidence: + +- `evidence/rebuild/task-22-site.txt` +- `evidence/rebuild/task-22-site.png` +- `evidence/rebuild/task-22-site-mobile.png` + +## Live Handoff Checklist + +GitHub push and PR: + +- Push branch `codex/gitdb-runtime-benchmark-site`. +- Open a draft PR with the Pull Request Draft section above. +- Attach benchmark summary from `docs/BENCHMARKS.md`. +- Confirm CI, Pages, and release workflow dry-run jobs are green. + +npm publish: + +- Configure npm trusted publisher for `3x-haust/gitdb`, workflow + `release.yml`, environment `npm-publish`, allowed action `npm publish`. +- Protect the GitHub environment `npm-publish` with required reviewers. +- Create and push a `v*` tag. +- Run the release workflow from that tag, or let the tag-gated workflow start. +- Confirm `npm publish --access public` runs without `NPM_TOKEN`. + +Pages deployment: + +- Merge to `main` only after PR checks pass. +- Confirm `.github/workflows/pages.yml` deploys `site`. +- Open `https://3x-haust.github.io/gitdb/` and verify the guarantee matrix, + benchmark table, API links, and npm install link. + +Post-release smoke: + +```bash +npm view @3xhaust/gitdb version +npm view @3xhaust/gitdb dist.tarball +npm pack @3xhaust/gitdb --dry-run +``` + +Then install the published package in a temporary project and run a minimal +`LocalPlaintextStore` repository insert/query smoke test. diff --git a/examples/api-encrypted-reopen/index.mjs b/examples/api-encrypted-reopen/index.mjs new file mode 100644 index 0000000..c52c807 --- /dev/null +++ b/examples/api-encrypted-reopen/index.mjs @@ -0,0 +1,95 @@ +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + createAesGcmCipher, + createGitDbDataSource, + defineEntity, + LocalEncryptedStore, +} from "../../dist/src/index.js" + +const Secret = defineEntity({ + columns: { + id: "STRING", + payload: "STRING", + }, + primaryKey: "id", + tableName: "secrets", +}) + +const root = await mkdtemp(join(tmpdir(), "gitdb-api-encrypted-reopen-")) +const key = Buffer.alloc(32, 11).toString("base64url") +const wrongKey = Buffer.alloc(32, 12).toString("base64url") +const secretValue = "launch-code-4271" + +try { + const entities = [Secret] + const dataSource = await createGitDbDataSource({ + entities, + store: new LocalEncryptedStore({ cipher: createAesGcmCipher(key), root }), + synchronize: true, + }) + + await dataSource.getRepository(Secret).insert({ id: "secret_1", payload: secretValue }) + + const reopened = await createGitDbDataSource({ + entities, + store: new LocalEncryptedStore({ cipher: createAesGcmCipher(key), root }), + synchronize: false, + }) + const rows = await reopened.getRepository(Secret).find() + const storedText = await readTree(root) + const leaksPlaintext = storedText.includes(secretValue) + const wrongKeyRejected = await rejectsWrongKey(entities) + + if (leaksPlaintext) { + throw new Error("encrypted example leaked plaintext into stored files") + } + if (!wrongKeyRejected) { + throw new Error("encrypted example did not reject a wrong key") + } + + const summary = { + example: "api-encrypted-reopen", + leaksPlaintext, + mode: "local-encrypted", + reopenedRows: rows, + status: "ok", + wrongKeyRejected, + } + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`) +} finally { + await rm(root, { force: true, recursive: true }) +} + +async function rejectsWrongKey(entities) { + try { + await createGitDbDataSource({ + entities, + store: new LocalEncryptedStore({ cipher: createAesGcmCipher(wrongKey), root }), + synchronize: false, + }) + return false + } catch (error) { + if (error instanceof Error) { + return true + } + throw error + } +} + +async function readTree(path) { + const entries = await readdir(path, { withFileTypes: true }) + const chunks = [] + for (const entry of entries) { + const child = join(path, entry.name) + if (entry.isDirectory()) { + chunks.push(await readTree(child)) + continue + } + if (entry.isFile()) { + chunks.push(await readFile(child, "utf8")) + } + } + return chunks.join("\n") +} diff --git a/examples/api-encrypted/index.mjs b/examples/api-encrypted/index.mjs new file mode 100644 index 0000000..789882e --- /dev/null +++ b/examples/api-encrypted/index.mjs @@ -0,0 +1,55 @@ +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + createAesGcmCipher, + createGitDbDataSource, + defineEntity, + LocalEncryptedStore, +} from "../../dist/src/index.js" + +const Account = defineEntity({ + columns: { + id: "STRING", + tier: "STRING", + status: "STRING", + }, + indexes: [{ columns: ["status"], name: "accounts_status_idx" }], + primaryKey: "id", + tableName: "accounts", +}) + +const key = Buffer.alloc(32, 7).toString("base64url") +const root = await mkdtemp(join(tmpdir(), "gitdb-api-encrypted-")) + +try { + const entities = [Account] + const dataSource = await createGitDbDataSource({ + entities, + store: new LocalEncryptedStore({ cipher: createAesGcmCipher(key), root }), + synchronize: true, + }) + const accounts = dataSource.getRepository(Account) + + await accounts.insertMany([ + { id: "acct_1", tier: "team", status: "active" }, + { id: "acct_2", tier: "free", status: "trial" }, + ]) + await accounts.saveMany([{ id: "acct_2", tier: "pro", status: "active" }]) + + const reopened = await createGitDbDataSource({ + entities, + store: new LocalEncryptedStore({ cipher: createAesGcmCipher(key), root }), + synchronize: false, + }) + + const summary = { + activeAccounts: await reopened.getRepository(Account).find({ where: { status: "active" } }), + example: "api-encrypted", + mode: "local-encrypted", + status: "ok", + } + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`) +} finally { + await rm(root, { force: true, recursive: true }) +} diff --git a/examples/api-local/index.mjs b/examples/api-local/index.mjs new file mode 100644 index 0000000..50fc9f5 --- /dev/null +++ b/examples/api-local/index.mjs @@ -0,0 +1,69 @@ +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createGitDbDataSource, defineEntity, LocalPlaintextStore } from "../../dist/src/index.js" + +const Team = defineEntity({ + columns: { + id: "STRING", + name: "STRING", + }, + primaryKey: "id", + tableName: "teams", +}) + +const Person = defineEntity({ + columns: { + id: "STRING", + name: "STRING", + team_id: "STRING", + }, + indexes: [{ columns: ["team_id"], name: "people_team_id_idx" }], + primaryKey: "id", + tableName: "people", +}) + +const root = await mkdtemp(join(tmpdir(), "gitdb-api-local-")) + +try { + const entities = [Team, Person] + const dataSource = await createGitDbDataSource({ + entities, + store: new LocalPlaintextStore({ root }), + synchronize: true, + }) + + const teams = dataSource.getRepository(Team) + const people = dataSource.getRepository(Person) + + await teams.insertMany([ + { id: "t1", name: "Storage" }, + { id: "t2", name: "Runtime" }, + ]) + await people.insertMany([ + { id: "p1", name: "Lin", team_id: "t1" }, + { id: "p2", name: "Ada", team_id: "t2" }, + { id: "p3", name: "Grace", team_id: "t1" }, + ]) + await people.saveMany([{ id: "p2", name: "Ada Lovelace", team_id: "t2" }]) + + const joined = await dataSource.query( + "SELECT people.name AS person, teams.name AS team FROM people JOIN teams ON people.team_id = teams.id", + ) + const reopened = await createGitDbDataSource({ + entities, + store: new LocalPlaintextStore({ root }), + synchronize: false, + }) + + const summary = { + example: "api-local", + joined, + mode: "local-plaintext", + reopenedPeople: await reopened.getRepository(Person).find({ where: { team_id: "t1" } }), + status: "ok", + } + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`) +} finally { + await rm(root, { force: true, recursive: true }) +} diff --git a/examples/express-prisma/.env.example b/examples/express-prisma/.env.example deleted file mode 100644 index 57f06ad..0000000 --- a/examples/express-prisma/.env.example +++ /dev/null @@ -1,10 +0,0 @@ -GITDB_ENCRYPTION=off -GITDB_ROOT=.gitdb-example-public -GITDB_HOST=127.0.0.1 -GITDB_PORT=0 -GITDB_GITHUB_OWNER=3x-haust -GITDB_GITHUB_REPO=gitdb-example-db -GITDB_GITHUB_BRANCH=main -GITDB_GITHUB_PREFIX=gitdb/v1 -GITDB_GITHUB_TOKEN= -API_PORT=3090 diff --git a/examples/express-prisma/schema.prisma b/examples/express-prisma/schema.prisma deleted file mode 100644 index fc34d5d..0000000 --- a/examples/express-prisma/schema.prisma +++ /dev/null @@ -1,8 +0,0 @@ -generator client { - provider = "prisma-client-js" - output = "./generated/client" -} - -datasource db { - provider = "postgresql" -} diff --git a/examples/express-prisma/server.mjs b/examples/express-prisma/server.mjs deleted file mode 100644 index 08c1e7a..0000000 --- a/examples/express-prisma/server.mjs +++ /dev/null @@ -1,94 +0,0 @@ -import { PrismaPg } from "@prisma/adapter-pg" -import { config as loadEnv } from "dotenv" -import express from "express" -import { createStoreFromEnv } from "../../dist/src/cli/store-factory.js" -import { createGitDbServer } from "../../dist/src/protocol/postgres-server.js" -import { GitDbEngine } from "../../dist/src/sql/engine.js" -import { PrismaClient } from "./generated/client/index.js" - -const envFile = {} -loadEnv({ path: new URL(".env", import.meta.url), processEnv: envFile }) - -const env = { - ...envFile, - ...process.env, - GITDB_ENCRYPTION: process.env.GITDB_ENCRYPTION ?? envFile.GITDB_ENCRYPTION ?? "off", - GITDB_HOST: process.env.GITDB_HOST ?? envFile.GITDB_HOST ?? "127.0.0.1", - GITDB_PORT: process.env.GITDB_PORT ?? envFile.GITDB_PORT ?? "0", - GITDB_ROOT: process.env.GITDB_ROOT ?? envFile.GITDB_ROOT ?? ".gitdb-example-public", -} - -const { mode, store } = createStoreFromEnv(env) -const engine = await GitDbEngine.open({ store }) -const server = await createGitDbServer({ - engine, - host: env.GITDB_HOST, - port: Number.parseInt(env.GITDB_PORT, 10), -}) -const databaseUrl = `postgresql://${server.host}:${server.port}/main` -const adapter = new PrismaPg({ connectionString: databaseUrl }) - -const prisma = new PrismaClient({ - adapter, -}) -const app = express() -const apiPort = Number.parseInt(process.env.API_PORT ?? envFile.API_PORT ?? "3090", 10) - -app.use(express.json()) - -await prisma.$executeRawUnsafe("CREATE TABLE IF NOT EXISTS teams (id STRING, name STRING)") -await prisma.$executeRawUnsafe( - "CREATE TABLE IF NOT EXISTS people (id STRING, name STRING, team_id STRING)", -) - -app.get("/health", (_request, response) => { - response.json({ databaseUrl, mode, status: "ready" }) -}) - -app.post("/seed", async (_request, response) => { - await prisma.$executeRawUnsafe("DELETE FROM people") - await prisma.$executeRawUnsafe("DELETE FROM teams") - await prisma.$executeRawUnsafe("INSERT INTO teams VALUES ('t1', 'Storage')") - await prisma.$executeRawUnsafe("INSERT INTO teams VALUES ('t2', 'Runtime')") - await prisma.$executeRawUnsafe("INSERT INTO people VALUES ('p1', 'Lin', 't1')") - await prisma.$executeRawUnsafe("INSERT INTO people VALUES ('p2', 'Ada', 't2')") - response.status(201).json({ inserted: 4 }) -}) - -app.get("/people", async (_request, response) => { - const rows = await prisma.$queryRawUnsafe(` - SELECT people.name AS person, teams.name AS team - FROM people - JOIN teams ON people.team_id = teams.id - ORDER BY people.name - `) - response.json({ rows }) -}) - -const httpServer = app.listen(apiPort, "127.0.0.1", () => { - process.stdout.write(`Express API: http://127.0.0.1:${apiPort}\n`) - process.stdout.write(`GitDB facade: ${databaseUrl}\n`) - process.stdout.write(`GitDB mode: ${mode}\n`) - process.stdout.write(`Try: curl -X POST http://127.0.0.1:${apiPort}/seed\n`) - process.stdout.write(`Try: curl http://127.0.0.1:${apiPort}/people\n`) -}) - -async function shutdown() { - await prisma.$disconnect() - await server.close() - await new Promise((resolve, reject) => { - httpServer.close((error) => { - if (error) { - reject(error) - return - } - resolve() - }) - }) -} - -process.on("SIGINT", () => { - void shutdown().finally(() => { - process.exit(0) - }) -}) diff --git a/main.ts b/main.ts index c9faf9c..d17c71c 100644 --- a/main.ts +++ b/main.ts @@ -1 +1 @@ -import "./src/http/main.js" +import "./src/cli/main.js" diff --git a/package.json b/package.json index a7cce31..3e67030 100644 --- a/package.json +++ b/package.json @@ -1,56 +1,58 @@ { "name": "@3xhaust/gitdb", "version": "0.1.0", - "description": "GitHub-native encrypted database with a PostgreSQL-compatible facade for existing ORMs.", + "description": "GitHub-backed local database runtime with auditable storage, transactions, and ORM APIs.", "type": "module", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "homepage": "https://3x-haust.github.io/gitdb/", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } + }, "bin": { - "gitdb": "./dist/src/cli/main.js" + "gitdb": "dist/src/cli/main.js" }, "files": [ - "dist", + "dist/src", "README.md", - "docs" + "docs", + "examples" ], "scripts": { - "build": "tsc -p tsconfig.json", - "benchmark": "pnpm build && node scripts/benchmark.mjs", - "benchmark:github": "pnpm build && node scripts/benchmark.mjs --github", + "build": "rm -rf dist && tsc -p tsconfig.json", + "benchmark": "corepack pnpm build && node scripts/benchmark.mjs", + "benchmark:compare": "GITDB_BENCH_OUTPUT=.gitdb/bench-current.json corepack pnpm benchmark:evaluate > /dev/null && node scripts/benchmark-compare.mjs --current .gitdb/bench-current.json --baseline docs/BENCHMARK_BASELINE.json --baseline-label \"previous runtime baseline\" --output site/benchmark.json --markdown site/benchmark.md", + "benchmark:evaluate": "corepack pnpm build && node scripts/benchmark.mjs --json", + "benchmark:gate": "corepack pnpm build && node scripts/benchmark-gate.mjs", + "benchmark:site": "GITDB_SITE_OUTPUT=site/benchmark.json corepack pnpm benchmark:evaluate > /dev/null", + "benchmark:github": "corepack pnpm build && node scripts/benchmark.mjs --github", "check": "biome check . && tsc --noEmit", - "example": "pnpm example:express-prisma", - "example:express-prisma": "pnpm build && pnpm exec prisma generate --schema examples/express-prisma/schema.prisma && node examples/express-prisma/server.mjs", + "example": "corepack pnpm example:api", + "example:api": "corepack pnpm build && node examples/api-local/index.mjs && node examples/api-encrypted/index.mjs && node examples/api-encrypted-reopen/index.mjs", + "example:api-local": "corepack pnpm build && node examples/api-local/index.mjs", + "example:api-encrypted": "corepack pnpm build && node examples/api-encrypted/index.mjs", + "example:api-encrypted-reopen": "corepack pnpm build && node examples/api-encrypted-reopen/index.mjs", "format": "biome check --write .", + "pack:dry-run": "corepack pnpm build && COREPACK_ENABLE_STRICT=0 npm pack --dry-run --json", + "publish:dry-run": "corepack pnpm build && COREPACK_ENABLE_STRICT=0 npm publish --dry-run --access public", + "site:preview": "python3 -m http.server 4173 --directory site", "test": "vitest run", - "test:e2e": "vitest run tests/e2e.test.ts", - "start": "node dist/src/http/main.js", - "start:facade": "node dist/src/cli/main.js serve", - "start:prod": "node dist/src/http/main.js" + "test:e2e": "vitest run tests/e2e.test.ts" }, "dependencies": { - "@nestjs/common": "^11.1.25", - "@nestjs/core": "^11.1.25", - "@nestjs/platform-express": "^11.1.25", "@octokit/rest": "22.0.1", "alasql": "4.17.3", "commander": "15.0.0", - "pg-server": "0.1.8", - "pgsql-ast-parser": "12.0.2", "pino": "10.3.1", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.2", "zod": "4.4.3" }, "devDependencies": { "@biomejs/biome": "2.4.16", - "@prisma/adapter-pg": "^7.8.0", - "@prisma/client": "^7.8.0", - "@prisma/client-runtime-utils": "^7.8.0", - "@types/express": "^5.0.6", "@types/node": "25.9.2", - "@types/pg": "8.20.0", "dotenv": "^17.4.2", - "express": "^5.2.1", - "pg": "8.21.0", - "prisma": "^7.8.0", "typescript": "6.0.3", "vitest": "4.1.8" }, @@ -58,8 +60,12 @@ "node": ">=20.19.0" }, "license": "MIT", + "publishConfig": { + "access": "public", + "provenance": true + }, "repository": { "type": "git", - "url": "https://github.com/3x-haust/gitdb.git" + "url": "git+https://github.com/3x-haust/gitdb.git" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a632e7..bfa8b70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,15 +8,6 @@ importers: .: dependencies: - '@nestjs/common': - specifier: ^11.1.25 - version: 11.1.25(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': - specifier: ^11.1.25 - version: 11.1.25(@nestjs/common@11.1.25(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.25)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/platform-express': - specifier: ^11.1.25 - version: 11.1.25(@nestjs/common@11.1.25(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.25) '@octokit/rest': specifier: 22.0.1 version: 22.0.1 @@ -26,21 +17,9 @@ importers: commander: specifier: 15.0.0 version: 15.0.0 - pg-server: - specifier: 0.1.8 - version: 0.1.8 - pgsql-ast-parser: - specifier: 12.0.2 - version: 12.0.2 pino: specifier: 10.3.1 version: 10.3.1 - reflect-metadata: - specifier: ^0.2.2 - version: 0.2.2 - rxjs: - specifier: ^7.8.2 - version: 7.8.2 zod: specifier: 4.4.3 version: 4.4.3 @@ -48,36 +27,12 @@ importers: '@biomejs/biome': specifier: 2.4.16 version: 2.4.16 - '@prisma/adapter-pg': - specifier: ^7.8.0 - version: 7.8.0 - '@prisma/client': - specifier: ^7.8.0 - version: 7.8.0(prisma@7.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(typescript@6.0.3) - '@prisma/client-runtime-utils': - specifier: ^7.8.0 - version: 7.8.0 - '@types/express': - specifier: ^5.0.6 - version: 5.0.6 '@types/node': specifier: 25.9.2 version: 25.9.2 - '@types/pg': - specifier: 8.20.0 - version: 8.20.0 dotenv: specifier: ^17.4.2 version: 17.4.2 - express: - specifier: ^5.2.1 - version: 5.2.1 - pg: - specifier: 8.21.0 - version: 8.21.0 - prisma: - specifier: ^7.8.0 - version: 7.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) typescript: specifier: 6.0.3 version: 6.0.3 @@ -215,23 +170,6 @@ packages: cpu: [x64] os: [win32] - '@borewit/text-codec@0.2.2': - resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} - - '@electric-sql/pglite-socket@0.1.1': - resolution: {integrity: sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==} - hasBin: true - peerDependencies: - '@electric-sql/pglite': 0.4.1 - - '@electric-sql/pglite-tools@0.3.1': - resolution: {integrity: sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==} - peerDependencies: - '@electric-sql/pglite': 0.4.1 - - '@electric-sql/pglite@0.4.1': - resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==} - '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -241,12 +179,6 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@hono/node-server@1.19.11': - resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - '@isaacs/ttlcache@1.4.1': resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} engines: {node: '>=12'} @@ -278,56 +210,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@kurkle/color@0.3.4': - resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} - - '@lukeed/csprng@1.1.0': - resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} - engines: {node: '>=8'} - '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@nestjs/common@11.1.25': - resolution: {integrity: sha512-eObNI89zpPEHQWr9BxD5yQe6hcDjzT6sj/jng1tWBL4GXEgZzrVYOnFA4pzk1ZvDNHa3z7GD2Yy0Z06Ihv9y6g==} - peerDependencies: - class-transformer: '>=0.4.1' - class-validator: '>=0.13.2' - reflect-metadata: ^0.1.12 || ^0.2.0 - rxjs: ^7.1.0 - peerDependenciesMeta: - class-transformer: - optional: true - class-validator: - optional: true - - '@nestjs/core@11.1.25': - resolution: {integrity: sha512-tkpoJlSB1CPGWHzEw3AyiUZhazngzhzKw14DbF9Rb5Ny50GP0zV6diTFkahob+HqzDDVjK/2DTt41OMFa4lK5w==} - engines: {node: '>= 20'} - peerDependencies: - '@nestjs/common': ^11.0.0 - '@nestjs/microservices': ^11.0.0 - '@nestjs/platform-express': ^11.0.0 - '@nestjs/websockets': ^11.0.0 - reflect-metadata: ^0.1.12 || ^0.2.0 - rxjs: ^7.1.0 - peerDependenciesMeta: - '@nestjs/microservices': - optional: true - '@nestjs/platform-express': - optional: true - '@nestjs/websockets': - optional: true - - '@nestjs/platform-express@11.1.25': - resolution: {integrity: sha512-a4f4WIGG8oCF4lwh7SlVOZXif0EwC4fsM/JZY5/X7OkHd8rp7Dxnx+sB2lwKAtnP6A29os/IPdH2Rll+wVJ6Qg==} - peerDependencies: - '@nestjs/common': ^11.0.0 - '@nestjs/core': ^11.0.0 - '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -386,143 +274,6 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@prisma/adapter-pg@7.8.0': - resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==} - - '@prisma/client-runtime-utils@7.8.0': - resolution: {integrity: sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==} - - '@prisma/client@7.8.0': - resolution: {integrity: sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==} - engines: {node: ^20.19 || ^22.12 || >=24.0} - peerDependencies: - prisma: '*' - typescript: '>=5.4.0' - peerDependenciesMeta: - prisma: - optional: true - typescript: - optional: true - - '@prisma/config@7.8.0': - resolution: {integrity: sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==} - - '@prisma/debug@7.2.0': - resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} - - '@prisma/debug@7.8.0': - resolution: {integrity: sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==} - - '@prisma/dev@0.24.3': - resolution: {integrity: sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==} - - '@prisma/driver-adapter-utils@7.8.0': - resolution: {integrity: sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==} - - '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': - resolution: {integrity: sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==} - - '@prisma/engines@7.8.0': - resolution: {integrity: sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==} - - '@prisma/fetch-engine@7.8.0': - resolution: {integrity: sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==} - - '@prisma/get-platform@7.2.0': - resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} - - '@prisma/get-platform@7.8.0': - resolution: {integrity: sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==} - - '@prisma/query-plan-executor@7.2.0': - resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} - - '@prisma/streams-local@0.1.2': - resolution: {integrity: sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==} - engines: {bun: '>=1.3.6', node: '>=22.0.0'} - - '@prisma/studio-core@0.27.3': - resolution: {integrity: sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==} - engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} - peerDependencies: - '@types/react': ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - - '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - - '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-toggle@1.1.10': - resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@react-native/assets-registry@0.85.3': resolution: {integrity: sha512-u9ZiYP23vA2IFtdFQFmetzSmk6SM0xgKIoiOsr1hXNHjHaLhOm+/Ph1ud57wX6+Dbwdzx8coJgnzSKL3W21PCg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -683,40 +434,18 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tokenizer/inflate@0.4.1': - resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} - engines: {node: '>=18'} - - '@tokenizer/token@0.3.0': - resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/express-serve-static-core@5.1.1': - resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} - - '@types/express@5.0.6': - resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} - - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -729,24 +458,9 @@ packages: '@types/node@25.9.2': resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} - '@types/pg@8.20.0': - resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} - - '@types/qs@6.15.1': - resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} - - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - - '@types/serve-static@2.2.0': - resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} - '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -799,9 +513,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ajv@8.20.0: - resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - alasql@4.17.3: resolution: {integrity: sha512-EOBD6zfZJ1u6Y76672MapJOyahzjDIQGE7nspt4yKhBWZB2k0cIHHakx6iJaolKkjB4vMPIo09ysw52aQfZ9rw==} engines: {node: '>=15'} @@ -822,9 +533,6 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - append-field@1.0.0: - resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} - asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -836,10 +544,6 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - aws-ssl-profiles@1.1.2: - resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} - engines: {node: '>= 6.0.0'} - babel-plugin-syntax-hermes-parser@0.33.3: resolution: {integrity: sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==} @@ -857,13 +561,6 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} - better-result@2.9.2: - resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==} - - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} - engines: {node: '>=18'} - braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -879,30 +576,6 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - c12@3.3.4: - resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} - peerDependencies: - magicast: '*' - peerDependenciesMeta: - magicast: - optional: true - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -918,14 +591,6 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chart.js@4.5.1: - resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} - engines: {pnpm: '>=8'} - - chokidar@5.0.0: - resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} - engines: {node: '>= 20.19.0'} - chrome-launcher@0.15.2: resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} engines: {node: '>=12.13.0'} @@ -966,25 +631,10 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - concat-stream@2.0.0: - resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} - engines: {'0': node >= 6.0} - - confbox@0.2.4: - resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} - connect@3.7.0: resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} engines: {node: '>= 0.10.0'} - content-disposition@1.1.0: - resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} - engines: {node: '>=18'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - content-type@2.0.0: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} @@ -992,18 +642,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - cross-fetch@4.1.0: resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} @@ -1031,24 +669,10 @@ packages: supports-color: optional: true - deepmerge-ts@7.1.5: - resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} - engines: {node: '>=16.0.0'} - - defu@6.1.7: - resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - - denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - destr@2.0.5: - resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1057,33 +681,19 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - discontinuous-range@1.0.0: - resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} - dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - effect@3.20.0: - resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} - electron-to-chromium@1.5.368: resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - empathic@2.0.0: - resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} - engines: {node: '>=14'} - encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -1092,28 +702,12 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - env-paths@3.0.0: - resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - es-object-atoms@1.1.2: - resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} - engines: {node: '>= 0.4'} - escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1143,26 +737,6 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} - engines: {node: '>= 18'} - - exsolve@1.0.8: - resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} - - fast-check@3.23.2: - resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} - engines: {node: '>=8.0.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - fb-dotslash@0.5.8: resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} engines: {node: '>=20'} @@ -1180,10 +754,6 @@ packages: picomatch: optional: true - file-type@21.3.4: - resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} - engines: {node: '>=20'} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1192,40 +762,18 @@ packages: resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} engines: {node: '>= 0.8'} - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.0.0'} - flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} - fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} - engines: {node: '>= 0.8'} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - generate-function@2.3.1: - resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} - gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1234,46 +782,13 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-port-please@3.2.0: - resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - giget@3.2.0: - resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} - hasBin: true - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - grammex@3.1.12: - resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} - - graphmatch@1.1.1: - resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - hasown@2.0.4: - resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} - engines: {node: '>= 0.4'} - hermes-compiler@250829098.0.10: resolution: {integrity: sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==} @@ -1289,28 +804,14 @@ packages: hermes-parser@0.35.0: resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==} - hono@4.12.24: - resolution: {integrity: sha512-I36D1s+HgQc55KbhEr4iybfxv/9o1zdpw+XEM6dJa91LqQD0HCoSGdxpRJCZE+aavs87j4V3Ls2OJzq8C/U4iw==} - engines: {node: '>=16.9.0'} - http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} - http-status-codes@2.3.0: - resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} - https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - image-size@1.2.1: resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} engines: {node: '>=16.x'} @@ -1322,10 +823,6 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} @@ -1339,12 +836,6 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-promise@4.0.0: - resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - - is-property@1.0.2: - resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} - is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} @@ -1352,10 +843,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - iterare@1.2.1: - resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} - engines: {node: '>=6'} - jest-get-type@29.6.3: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1387,9 +874,6 @@ packages: engines: {node: '>=6'} hasBin: true - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json-with-bigint@3.5.8: resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} @@ -1479,16 +963,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - load-esm@1.0.3: - resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} - engines: {node: '>=13.2.0'} - lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} - long@5.3.2: - resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} - loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1496,10 +973,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru.min@1.1.4: - resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} - engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1509,25 +982,9 @@ packages: marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} - memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} - merge-descriptors@2.0.0: - resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} - engines: {node: '>=18'} - merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -1593,18 +1050,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -1619,36 +1068,17 @@ packages: engines: {node: '>=10'} hasBin: true - moo@0.5.3: - resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} - ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - multer@2.1.1: - resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} - engines: {node: '>= 10.16.0'} - - mysql2@3.15.3: - resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} - engines: {node: '>= 8.0'} - - named-placeholders@1.1.6: - resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} - engines: {node: '>=8.0.0'} - nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nearley@2.20.1: - resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} - hasBin: true - negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -1676,21 +1106,10 @@ packages: resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - obug@2.1.2: resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} engines: {node: '>=12.20.0'} - ohash@2.0.11: - resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -1703,9 +1122,6 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - open@7.4.2: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} @@ -1718,55 +1134,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-to-regexp@8.4.2: - resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - perfect-debounce@2.1.0: - resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} - - pg-cloudflare@1.4.0: - resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} - - pg-connection-string@2.13.0: - resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} - - pg-int8@1.0.1: - resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} - engines: {node: '>=4.0.0'} - - pg-pool@3.14.0: - resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} - peerDependencies: - pg: '>=8.0' - - pg-protocol@1.14.0: - resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} - - pg-server@0.1.8: - resolution: {integrity: sha512-iSgMkGSJsRlXrOaM/W1bPEbf0BKbprKsyTidKrGSDkQfZdBiA6aAhaWB80ectwt/JZBeiwIkkIFCKLTEYSnE1Q==} - - pg-types@2.2.0: - resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} - engines: {node: '>=4'} - - pg@8.21.0: - resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} - engines: {node: '>= 16.0.0'} - peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true - - pgpass@1.0.5: - resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} - - pgsql-ast-parser@12.0.2: - resolution: {integrity: sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1788,106 +1158,33 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true - pkg-types@2.3.1: - resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} - postgres-array@2.0.0: - resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} - engines: {node: '>=4'} - - postgres-array@3.0.4: - resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} - engines: {node: '>=12'} - - postgres-bytea@1.0.1: - resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} - engines: {node: '>=0.10.0'} - - postgres-date@1.0.7: - resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} - engines: {node: '>=0.10.0'} - - postgres-interval@1.2.0: - resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} - engines: {node: '>=0.10.0'} - - postgres@3.4.7: - resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} - engines: {node: '>=12'} - pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - prisma@7.8.0: - resolution: {integrity: sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==} - engines: {node: ^20.19 || ^22.12 || >=24.0} - hasBin: true - peerDependencies: - better-sqlite3: '>=9.0.0' - typescript: '>=5.4.0' - peerDependenciesMeta: - better-sqlite3: - optional: true - typescript: - optional: true - process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} promise@8.3.0: resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} - proper-lockfile@4.1.2: - resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - - qs@6.15.2: - resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} - engines: {node: '>=0.6'} - - queue@6.0.2: - resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} - - quick-format-unescaped@4.0.4: - resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - - railroad-diagrams@1.0.0: - resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} - - randexp@0.4.6: - resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} - engines: {node: '>=0.12'} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} - - rc9@3.0.1: - resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} - react-devtools-core@6.1.5: resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} - react-dom@19.2.7: - resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} - peerDependencies: - react: ^19.2.7 - react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -1922,14 +1219,6 @@ packages: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} - engines: {node: '>= 20.19.0'} - real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -1937,53 +1226,22 @@ packages: real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} - reflect-metadata@0.2.2: - resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} - remeda@2.33.4: - resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - ret@0.1.15: - resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} - engines: {node: '>=0.12'} - - retry@0.12.0: - resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} - engines: {node: '>= 4'} - rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} - - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2000,13 +1258,6 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} - - seq-queue@0.0.5: - resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} - serialize-error@2.1.0: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} @@ -2015,10 +1266,6 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} - setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -2034,32 +1281,9 @@ packages: resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -2082,10 +1306,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - sqlstring@2.3.3: - resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} - engines: {node: '>= 0.6'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2104,31 +1324,17 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strtok3@10.3.5: - resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} - engines: {node: '>=18'} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2175,10 +1381,6 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - token-types@6.1.2: - resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} - engines: {node: '>=14.16'} - tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -2189,30 +1391,11 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - - type-is@2.1.0: - resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} - engines: {node: '>= 18'} - - typedarray@0.0.6: - resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true - uid@2.0.2: - resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} - engines: {node: '>=8'} - - uint8array-extras@1.5.0: - resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} - engines: {node: '>=18'} - undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -2232,25 +1415,10 @@ packages: utf8@3.0.0: resolution: {integrity: sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - valibot@1.2.0: - resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} - peerDependencies: - typescript: '>=5' - peerDependenciesMeta: - typescript: - optional: true - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2364,9 +1532,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@7.5.11: resolution: {integrity: sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==} engines: {node: '>=8.3.0'} @@ -2379,10 +1544,6 @@ packages: utf-8-validate: optional: true - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2411,9 +1572,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - zeptomatch@2.1.0: - resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} - zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -2573,18 +1731,6 @@ snapshots: '@biomejs/cli-win32-x64@2.4.16': optional: true - '@borewit/text-codec@0.2.2': {} - - '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': - dependencies: - '@electric-sql/pglite': 0.4.1 - - '@electric-sql/pglite-tools@0.3.1(@electric-sql/pglite@0.4.1)': - dependencies: - '@electric-sql/pglite': 0.4.1 - - '@electric-sql/pglite@0.4.1': {} - '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2601,10 +1747,6 @@ snapshots: tslib: 2.8.1 optional: true - '@hono/node-server@1.19.11(hono@4.12.24)': - dependencies: - hono: 4.12.24 - '@isaacs/ttlcache@1.4.1': optional: true @@ -2652,10 +1794,6 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 optional: true - '@kurkle/color@0.3.4': {} - - '@lukeed/csprng@1.1.0': {} - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2663,43 +1801,6 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@nestjs/common@11.1.25(reflect-metadata@0.2.2)(rxjs@7.8.2)': - dependencies: - file-type: 21.3.4 - iterare: 1.2.1 - load-esm: 1.0.3 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - tslib: 2.8.1 - uid: 2.0.2 - transitivePeerDependencies: - - supports-color - - '@nestjs/core@11.1.25(@nestjs/common@11.1.25(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.25)(reflect-metadata@0.2.2)(rxjs@7.8.2)': - dependencies: - '@nestjs/common': 11.1.25(reflect-metadata@0.2.2)(rxjs@7.8.2) - fast-safe-stringify: 2.1.1 - iterare: 1.2.1 - path-to-regexp: 8.4.2 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - tslib: 2.8.1 - uid: 2.0.2 - optionalDependencies: - '@nestjs/platform-express': 11.1.25(@nestjs/common@11.1.25(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.25) - - '@nestjs/platform-express@11.1.25(@nestjs/common@11.1.25(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.25)': - dependencies: - '@nestjs/common': 11.1.25(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.25(@nestjs/common@11.1.25(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.25)(reflect-metadata@0.2.2)(rxjs@7.8.2) - cors: 2.8.6 - express: 5.2.1 - multer: 2.1.1 - path-to-regexp: 8.4.2 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.6': @@ -2767,159 +1868,6 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@prisma/adapter-pg@7.8.0': - dependencies: - '@prisma/driver-adapter-utils': 7.8.0 - '@types/pg': 8.20.0 - pg: 8.21.0 - postgres-array: 3.0.4 - transitivePeerDependencies: - - pg-native - - '@prisma/client-runtime-utils@7.8.0': {} - - '@prisma/client@7.8.0(prisma@7.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(typescript@6.0.3)': - dependencies: - '@prisma/client-runtime-utils': 7.8.0 - optionalDependencies: - prisma: 7.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) - typescript: 6.0.3 - - '@prisma/config@7.8.0': - dependencies: - c12: 3.3.4 - deepmerge-ts: 7.1.5 - effect: 3.20.0 - empathic: 2.0.0 - transitivePeerDependencies: - - magicast - - '@prisma/debug@7.2.0': {} - - '@prisma/debug@7.8.0': {} - - '@prisma/dev@0.24.3(typescript@6.0.3)': - dependencies: - '@electric-sql/pglite': 0.4.1 - '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) - '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) - '@hono/node-server': 1.19.11(hono@4.12.24) - '@prisma/get-platform': 7.2.0 - '@prisma/query-plan-executor': 7.2.0 - '@prisma/streams-local': 0.1.2 - foreground-child: 3.3.1 - get-port-please: 3.2.0 - hono: 4.12.24 - http-status-codes: 2.3.0 - pathe: 2.0.3 - proper-lockfile: 4.1.2 - remeda: 2.33.4 - std-env: 3.10.0 - valibot: 1.2.0(typescript@6.0.3) - zeptomatch: 2.1.0 - transitivePeerDependencies: - - typescript - - '@prisma/driver-adapter-utils@7.8.0': - dependencies: - '@prisma/debug': 7.8.0 - - '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': {} - - '@prisma/engines@7.8.0': - dependencies: - '@prisma/debug': 7.8.0 - '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a - '@prisma/fetch-engine': 7.8.0 - '@prisma/get-platform': 7.8.0 - - '@prisma/fetch-engine@7.8.0': - dependencies: - '@prisma/debug': 7.8.0 - '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a - '@prisma/get-platform': 7.8.0 - - '@prisma/get-platform@7.2.0': - dependencies: - '@prisma/debug': 7.2.0 - - '@prisma/get-platform@7.8.0': - dependencies: - '@prisma/debug': 7.8.0 - - '@prisma/query-plan-executor@7.2.0': {} - - '@prisma/streams-local@0.1.2': - dependencies: - ajv: 8.20.0 - better-result: 2.9.2 - env-paths: 3.0.0 - proper-lockfile: 4.1.2 - - '@prisma/studio-core@0.27.3(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/react-toggle': 1.1.10(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@types/react': 19.2.17 - chart.js: 4.5.1 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - transitivePeerDependencies: - - '@types/react-dom' - - '@radix-ui/primitive@1.1.3': {} - - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.17)(react@19.2.7)': - dependencies: - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - - '@radix-ui/react-primitive@2.1.3(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - - '@radix-ui/react-slot@1.2.3(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - - '@radix-ui/react-toggle@1.1.10(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.17)(react@19.2.7)': - dependencies: - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@react-native/assets-registry@0.85.3': optional: true @@ -3056,53 +2004,20 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tokenizer/inflate@0.4.1': - dependencies: - debug: 4.4.3 - token-types: 6.1.2 - transitivePeerDependencies: - - supports-color - - '@tokenizer/token@0.3.0': {} - '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 25.9.2 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/connect@3.4.38': - dependencies: - '@types/node': 25.9.2 - '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} - '@types/express-serve-static-core@5.1.1': - dependencies: - '@types/node': 25.9.2 - '@types/qs': 6.15.1 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - - '@types/express@5.0.6': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 5.1.1 - '@types/serve-static': 2.2.0 - - '@types/http-errors@2.0.5': {} - '@types/istanbul-lib-coverage@2.0.6': optional: true @@ -3120,28 +2035,10 @@ snapshots: dependencies: undici-types: 7.24.6 - '@types/pg@8.20.0': - dependencies: - '@types/node': 25.9.2 - pg-protocol: 1.14.0 - pg-types: 2.2.0 - - '@types/qs@6.15.1': {} - - '@types/range-parser@1.2.7': {} - '@types/react@19.2.17': dependencies: csstype: 3.2.3 - - '@types/send@1.2.1': - dependencies: - '@types/node': 25.9.2 - - '@types/serve-static@2.2.0': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 25.9.2 + optional: true '@types/yargs-parser@21.0.3': optional: true @@ -3201,6 +2098,7 @@ snapshots: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 + optional: true acorn@8.16.0: optional: true @@ -3208,13 +2106,6 @@ snapshots: agent-base@7.1.4: optional: true - ajv@8.20.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - alasql@4.17.3(react-native@0.85.3(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.7)): dependencies: cross-fetch: 4.1.0 @@ -3238,8 +2129,6 @@ snapshots: ansi-styles@5.2.0: optional: true - append-field@1.0.0: {} - asap@2.0.6: optional: true @@ -3247,8 +2136,6 @@ snapshots: atomic-sleep@1.0.0: {} - aws-ssl-profiles@1.1.2: {} - babel-plugin-syntax-hermes-parser@0.33.3: dependencies: hermes-parser: 0.33.3 @@ -3265,22 +2152,6 @@ snapshots: before-after-hook@4.0.0: {} - better-result@2.9.2: {} - - body-parser@2.2.2: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - on-finished: 2.4.1 - qs: 6.15.2 - raw-body: 3.0.2 - type-is: 2.1.0 - transitivePeerDependencies: - - supports-color - braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -3300,38 +2171,8 @@ snapshots: node-int64: 0.4.0 optional: true - buffer-from@1.1.2: {} - - busboy@1.6.0: - dependencies: - streamsearch: 1.1.0 - - bytes@3.1.2: {} - - c12@3.3.4: - dependencies: - chokidar: 5.0.0 - confbox: 0.2.4 - defu: 6.1.7 - dotenv: 17.4.2 - exsolve: 1.0.8 - giget: 3.2.0 - jiti: 2.7.0 - ohash: 2.0.11 - pathe: 2.0.3 - perfect-debounce: 2.1.0 - pkg-types: 2.3.1 - rc9: 3.0.1 - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 + buffer-from@1.1.2: + optional: true camelcase@6.3.0: optional: true @@ -3347,14 +2188,6 @@ snapshots: supports-color: 7.2.0 optional: true - chart.js@4.5.1: - dependencies: - '@kurkle/color': 0.3.4 - - chokidar@5.0.0: - dependencies: - readdirp: 5.0.0 - chrome-launcher@0.15.2: dependencies: '@types/node': 25.9.2 @@ -3406,16 +2239,8 @@ snapshots: commander@15.0.0: {} - commander@2.20.3: {} - - concat-stream@2.0.0: - dependencies: - buffer-from: 1.1.2 - inherits: 2.0.4 - readable-stream: 3.6.2 - typedarray: 0.0.6 - - confbox@0.2.4: {} + commander@2.20.3: + optional: true connect@3.7.0: dependencies: @@ -3427,23 +2252,10 @@ snapshots: - supports-color optional: true - content-disposition@1.1.0: {} - - content-type@1.0.5: {} - content-type@2.0.0: {} convert-source-map@2.0.0: {} - cookie-signature@1.2.2: {} - - cookie@0.7.2: {} - - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - cross-fetch@4.1.0: dependencies: node-fetch: 2.7.0 @@ -3455,8 +2267,10 @@ snapshots: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + optional: true - csstype@3.2.3: {} + csstype@3.2.3: + optional: true debug@2.6.9: dependencies: @@ -3466,71 +2280,43 @@ snapshots: debug@4.4.3: dependencies: ms: 2.1.3 + optional: true - deepmerge-ts@7.1.5: {} - - defu@6.1.7: {} - - denque@2.1.0: {} - - depd@2.0.0: {} - - destr@2.0.5: {} + depd@2.0.0: + optional: true destroy@1.2.0: optional: true detect-libc@2.1.2: {} - discontinuous-range@1.0.0: {} - dotenv@17.4.2: {} - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - ee-first@1.1.1: {} - - effect@3.20.0: - dependencies: - '@standard-schema/spec': 1.1.0 - fast-check: 3.23.2 + ee-first@1.1.1: + optional: true electron-to-chromium@1.5.368: optional: true emoji-regex@8.0.0: {} - empathic@2.0.0: {} - encodeurl@1.0.2: optional: true - encodeurl@2.0.0: {} - - env-paths@3.0.0: {} + encodeurl@2.0.0: + optional: true error-stack-parser@2.1.4: dependencies: stackframe: 1.3.4 optional: true - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - es-module-lexer@2.1.0: {} - es-object-atoms@1.1.2: - dependencies: - es-errors: 1.3.0 - escalade@3.2.0: {} - escape-html@1.0.3: {} + escape-html@1.0.3: + optional: true escape-string-regexp@4.0.0: optional: true @@ -3539,7 +2325,8 @@ snapshots: dependencies: '@types/estree': 1.0.9 - etag@1.8.1: {} + etag@1.8.1: + optional: true event-target-shim@5.0.1: optional: true @@ -3549,51 +2336,6 @@ snapshots: exponential-backoff@3.1.3: optional: true - express@5.2.1: - dependencies: - accepts: 2.0.0 - body-parser: 2.2.2 - content-disposition: 1.1.0 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.3 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.1 - fresh: 2.0.0 - http-errors: 2.0.1 - merge-descriptors: 2.0.0 - mime-types: 3.0.2 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.15.2 - range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 - statuses: 2.0.2 - type-is: 2.1.0 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - exsolve@1.0.8: {} - - fast-check@3.23.2: - dependencies: - pure-rand: 6.1.0 - - fast-deep-equal@3.1.3: {} - - fast-safe-stringify@2.1.1: {} - - fast-uri@3.1.2: {} - fb-dotslash@0.5.8: optional: true @@ -3606,15 +2348,6 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - file-type@21.3.4: - dependencies: - '@tokenizer/inflate': 0.4.1 - strtok3: 10.3.5 - token-types: 6.1.2 - uint8array-extras: 1.5.0 - transitivePeerDependencies: - - supports-color - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -3633,85 +2366,26 @@ snapshots: - supports-color optional: true - finalhandler@2.1.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - flow-enums-runtime@0.0.6: optional: true - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - forwarded@0.2.0: {} - fresh@0.5.2: optional: true - fresh@2.0.0: {} - fsevents@2.3.3: optional: true - function-bind@1.1.2: {} - - generate-function@2.3.1: - dependencies: - is-property: 1.0.2 - gensync@1.0.0-beta.2: optional: true get-caller-file@2.0.5: {} - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.4 - math-intrinsics: 1.1.0 - - get-port-please@3.2.0: {} - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.2 - - giget@3.2.0: {} - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - grammex@3.1.12: {} - - graphmatch@1.1.1: {} + graceful-fs@4.2.11: + optional: true has-flag@4.0.0: optional: true - has-symbols@1.1.0: {} - - hasown@2.0.4: - dependencies: - function-bind: 1.1.2 - hermes-compiler@250829098.0.10: optional: true @@ -3731,8 +2405,6 @@ snapshots: hermes-estree: 0.35.0 optional: true - hono@4.12.24: {} - http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -3740,8 +2412,7 @@ snapshots: setprototypeof: 1.2.0 statuses: 2.0.2 toidentifier: 1.0.1 - - http-status-codes@2.3.0: {} + optional: true https-proxy-agent@7.0.6: dependencies: @@ -3751,26 +2422,19 @@ snapshots: - supports-color optional: true - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - - ieee754@1.2.1: {} - image-size@1.2.1: dependencies: queue: 6.0.2 optional: true - inherits@2.0.4: {} + inherits@2.0.4: + optional: true invariant@2.2.4: dependencies: loose-envify: 1.4.0 optional: true - ipaddr.js@1.9.1: {} - is-docker@2.2.1: optional: true @@ -3779,18 +2443,13 @@ snapshots: is-number@7.0.0: optional: true - is-promise@4.0.0: {} - - is-property@1.0.2: {} - is-wsl@2.2.0: dependencies: is-docker: 2.2.1 optional: true - isexe@2.0.0: {} - - iterare@1.2.1: {} + isexe@2.0.0: + optional: true jest-get-type@29.6.3: optional: true @@ -3823,7 +2482,8 @@ snapshots: supports-color: 8.1.1 optional: true - jiti@2.7.0: {} + jiti@2.7.0: + optional: true js-tokens@4.0.0: optional: true @@ -3834,8 +2494,6 @@ snapshots: jsesc@3.1.0: optional: true - json-schema-traverse@1.0.0: {} - json-with-bigint@3.5.8: {} json5@2.2.3: @@ -3901,13 +2559,9 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - load-esm@1.0.3: {} - lodash.throttle@4.1.1: optional: true - long@5.3.2: {} - loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -3918,8 +2572,6 @@ snapshots: yallist: 3.1.1 optional: true - lru.min@1.1.4: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3932,17 +2584,9 @@ snapshots: marky@1.3.0: optional: true - math-intrinsics@1.1.0: {} - - media-typer@0.3.0: {} - - media-typer@1.1.0: {} - memoize-one@5.2.1: optional: true - merge-descriptors@2.0.0: {} - merge-stream@2.0.0: optional: true @@ -4140,17 +2784,13 @@ snapshots: picomatch: 2.3.2 optional: true - mime-db@1.52.0: {} - - mime-db@1.54.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 + mime-db@1.54.0: + optional: true mime-types@3.0.2: dependencies: mime-db: 1.54.0 + optional: true mime@1.6.0: optional: true @@ -4158,46 +2798,16 @@ snapshots: mkdirp@1.0.4: optional: true - moo@0.5.3: {} - ms@2.0.0: optional: true - ms@2.1.3: {} - - multer@2.1.1: - dependencies: - append-field: 1.0.0 - busboy: 1.6.0 - concat-stream: 2.0.0 - type-is: 1.6.18 - - mysql2@3.15.3: - dependencies: - aws-ssl-profiles: 1.1.2 - denque: 2.1.0 - generate-function: 2.3.1 - iconv-lite: 0.7.2 - long: 5.3.2 - lru.min: 1.1.4 - named-placeholders: 1.1.6 - seq-queue: 0.0.5 - sqlstring: 2.3.3 - - named-placeholders@1.1.6: - dependencies: - lru.min: 1.1.4 + ms@2.1.3: + optional: true nanoid@3.3.12: {} - nearley@2.20.1: - dependencies: - commander: 2.20.3 - moo: 0.5.3 - railroad-diagrams: 1.0.0 - randexp: 0.4.6 - - negotiator@1.0.0: {} + negotiator@1.0.0: + optional: true node-fetch@2.7.0: dependencies: @@ -4217,14 +2827,8 @@ snapshots: flow-enums-runtime: 0.0.6 optional: true - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - obug@2.1.2: {} - ohash@2.0.11: {} - on-exit-leak-free@2.1.2: {} on-finished@2.3.0: @@ -4235,10 +2839,7 @@ snapshots: on-finished@2.4.1: dependencies: ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 + optional: true open@7.4.2: dependencies: @@ -4246,59 +2847,13 @@ snapshots: is-wsl: 2.2.0 optional: true - parseurl@1.3.3: {} - - path-key@3.1.1: {} - - path-to-regexp@8.4.2: {} - - pathe@2.0.3: {} - - perfect-debounce@2.1.0: {} - - pg-cloudflare@1.4.0: + parseurl@1.3.3: optional: true - pg-connection-string@2.13.0: {} - - pg-int8@1.0.1: {} - - pg-pool@3.14.0(pg@8.21.0): - dependencies: - pg: 8.21.0 - - pg-protocol@1.14.0: {} - - pg-server@0.1.8: - dependencies: - pg-protocol: 1.14.0 - - pg-types@2.2.0: - dependencies: - pg-int8: 1.0.1 - postgres-array: 2.0.0 - postgres-bytea: 1.0.1 - postgres-date: 1.0.7 - postgres-interval: 1.2.0 - - pg@8.21.0: - dependencies: - pg-connection-string: 2.13.0 - pg-pool: 3.14.0(pg@8.21.0) - pg-protocol: 1.14.0 - pg-types: 2.2.0 - pgpass: 1.0.5 - optionalDependencies: - pg-cloudflare: 1.4.0 - - pgpass@1.0.5: - dependencies: - split2: 4.2.0 + path-key@3.1.1: + optional: true - pgsql-ast-parser@12.0.2: - dependencies: - moo: 0.5.3 - nearley: 2.20.1 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -4327,32 +2882,12 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 - pkg-types@2.3.1: - dependencies: - confbox: 0.2.4 - exsolve: 1.0.8 - pathe: 2.0.3 - postcss@8.5.15: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 - postgres-array@2.0.0: {} - - postgres-array@3.0.4: {} - - postgres-bytea@1.0.1: {} - - postgres-date@1.0.7: {} - - postgres-interval@1.2.0: - dependencies: - xtend: 4.0.2 - - postgres@3.4.7: {} - pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -4360,23 +2895,6 @@ snapshots: react-is: 18.3.1 optional: true - prisma@7.8.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): - dependencies: - '@prisma/config': 7.8.0 - '@prisma/dev': 0.24.3(typescript@6.0.3) - '@prisma/engines': 7.8.0 - '@prisma/studio-core': 0.27.3(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - mysql2: 3.15.3 - postgres: 3.4.7 - optionalDependencies: - typescript: 6.0.3 - transitivePeerDependencies: - - '@types/react' - - '@types/react-dom' - - magicast - - react - - react-dom - process-warning@5.0.0: {} promise@8.3.0: @@ -4384,23 +2902,6 @@ snapshots: asap: 2.0.6 optional: true - proper-lockfile@4.1.2: - dependencies: - graceful-fs: 4.2.11 - retry: 0.12.0 - signal-exit: 3.0.7 - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - pure-rand@6.1.0: {} - - qs@6.15.2: - dependencies: - side-channel: 1.1.0 - queue@6.0.2: dependencies: inherits: 2.0.4 @@ -4408,26 +2909,8 @@ snapshots: quick-format-unescaped@4.0.4: {} - railroad-diagrams@1.0.0: {} - - randexp@0.4.6: - dependencies: - discontinuous-range: 1.0.0 - ret: 0.1.15 - - range-parser@1.2.1: {} - - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - unpipe: 1.0.0 - - rc9@3.0.1: - dependencies: - defu: 6.1.7 - destr: 2.0.5 + range-parser@1.2.1: + optional: true react-devtools-core@6.1.5: dependencies: @@ -4438,11 +2921,6 @@ snapshots: - utf-8-validate optional: true - react-dom@19.2.7(react@19.2.7): - dependencies: - react: 19.2.7 - scheduler: 0.27.0 - react-is@18.3.1: optional: true @@ -4502,35 +2980,18 @@ snapshots: react-refresh@0.14.2: optional: true - react@19.2.7: {} - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - readdirp@5.0.0: {} + react@19.2.7: + optional: true real-require@0.2.0: {} real-require@1.0.0: {} - reflect-metadata@0.2.2: {} - regenerator-runtime@0.13.11: optional: true - remeda@2.33.4: {} - require-directory@2.1.1: {} - require-from-string@2.0.2: {} - - ret@0.1.15: {} - - retry@0.12.0: {} - rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -4552,27 +3013,10 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.3 '@rolldown/binding-win32-x64-msvc': 1.0.3 - router@2.2.0: - dependencies: - debug: 4.4.3 - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.4.2 - transitivePeerDependencies: - - supports-color - - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - - safe-buffer@5.2.1: {} - safe-stable-stringify@2.5.0: {} - safer-buffer@2.1.2: {} - - scheduler@0.27.0: {} + scheduler@0.27.0: + optional: true semver@6.3.1: optional: true @@ -4599,24 +3043,6 @@ snapshots: - supports-color optional: true - send@1.2.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.2 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - seq-queue@0.0.5: {} - serialize-error@2.1.0: optional: true @@ -4630,60 +3056,22 @@ snapshots: - supports-color optional: true - serve-static@2.2.1: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.1 - transitivePeerDependencies: - - supports-color - - setprototypeof@1.2.0: {} + setprototypeof@1.2.0: + optional: true shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 + optional: true - shebang-regex@3.0.0: {} + shebang-regex@3.0.0: + optional: true shell-quote@1.8.4: optional: true - side-channel-list@1.0.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - siginfo@2.0.0: {} - signal-exit@3.0.7: {} - - signal-exit@4.1.0: {} - sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -4704,8 +3092,6 @@ snapshots: split2@4.2.0: {} - sqlstring@2.3.3: {} - stackback@0.0.2: {} stackframe@1.3.4: @@ -4719,32 +3105,21 @@ snapshots: statuses@1.5.0: optional: true - statuses@2.0.2: {} - - std-env@3.10.0: {} + statuses@2.0.2: + optional: true std-env@4.1.0: {} - streamsearch@1.1.0: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 - strtok3@10.3.5: - dependencies: - '@tokenizer/token': 0.3.0 - supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -4789,47 +3164,25 @@ snapshots: is-number: 7.0.0 optional: true - toidentifier@1.0.1: {} - - token-types@6.1.2: - dependencies: - '@borewit/text-codec': 0.2.2 - '@tokenizer/token': 0.3.0 - ieee754: 1.2.1 + toidentifier@1.0.1: + optional: true tr46@0.0.3: {} - tslib@2.8.1: {} + tslib@2.8.1: + optional: true type-fest@0.7.1: optional: true - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - - type-is@2.1.0: - dependencies: - content-type: 2.0.0 - media-typer: 1.1.0 - mime-types: 3.0.2 - - typedarray@0.0.6: {} - typescript@6.0.3: {} - uid@2.0.2: - dependencies: - '@lukeed/csprng': 1.1.0 - - uint8array-extras@1.5.0: {} - undici-types@7.24.6: {} universal-user-agent@7.0.3: {} - unpipe@1.0.0: {} + unpipe@1.0.0: + optional: true update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: @@ -4841,17 +3194,9 @@ snapshots: utf8@3.0.0: optional: true - util-deprecate@1.0.2: {} - utils-merge@1.0.1: optional: true - valibot@1.2.0(typescript@6.0.3): - optionalDependencies: - typescript: 6.0.3 - - vary@1.1.2: {} - vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -4914,6 +3259,7 @@ snapshots: which@2.0.2: dependencies: isexe: 2.0.0 + optional: true why-is-node-running@2.3.0: dependencies: @@ -4926,13 +3272,9 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrappy@1.0.2: {} - ws@7.5.11: optional: true - xtend@4.0.2: {} - y18n@5.0.8: {} yallist@3.1.1: @@ -4967,9 +3309,4 @@ snapshots: yargs-parser: 21.1.1 optional: true - zeptomatch@2.1.0: - dependencies: - grammex: 3.1.12 - graphmatch: 1.1.1 - zod@4.4.3: {} diff --git a/scripts/benchmark-compare.mjs b/scripts/benchmark-compare.mjs new file mode 100644 index 0000000..8dd5f3b --- /dev/null +++ b/scripts/benchmark-compare.mjs @@ -0,0 +1,108 @@ +import { execFile } from "node:child_process" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { dirname } from "node:path" +import { promisify } from "node:util" +import { + buildBenchmarkEvidence, + formatComparisonMarkdown, + parseBenchmarkEvidence, + parseBenchmarkOutput, +} from "./benchmark-report.mjs" + +const execFileAsync = promisify(execFile) +const options = parseArgs(process.argv.slice(2)) +const currentPath = requireValue(options.current, "--current") +const baselineSource = await loadBaselineSource(options) +const currentText = await readFile(currentPath, "utf8") +const baselineText = baselineSource.text +const currentEvidence = parseBenchmarkEvidence(currentText) +const evidence = buildBenchmarkEvidence({ + baseline: parseBenchmarkOutput(baselineText), + baselineLabel: options.baselineLabel ?? "previous documented run", + baselineSource: baselineSource.source, + current: currentEvidence.results, + currentLabel: options.currentLabel ?? "current working tree", + currentMetadata: currentEvidence.metadata, + currentSource: currentPath, +}) +const markdown = formatComparisonMarkdown(evidence) + +if (options.output !== undefined) { + await writeJson(options.output, evidence) +} + +if (options.markdown !== undefined) { + await writeText(options.markdown, markdown) +} + +process.stdout.write(markdown) + +function parseArgs(argv) { + const parsed = {} + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + switch (arg) { + case "--baseline": + case "--baseline-label": + case "--baseline-path": + case "--baseline-ref": + case "--current": + case "--current-label": + case "--markdown": + case "--output": + parsed[toCamelCase(arg.slice(2))] = readArgValue(argv, index, arg) + index += 1 + break + default: + throw new Error(`unknown argument: ${arg}`) + } + } + return parsed +} + +function readArgValue(argv, index, name) { + const value = argv[index + 1] + if (value === undefined || value.startsWith("--")) { + throw new Error(`${name} requires a value`) + } + return value +} + +function toCamelCase(value) { + return value.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase()) +} + +async function loadBaselineSource(parsed) { + if (parsed.baseline !== undefined) { + return { + source: parsed.baseline, + text: await readFile(parsed.baseline, "utf8"), + } + } + + const baselinePath = parsed.baselinePath ?? "docs/BENCHMARKS.md" + const baselineRef = parsed.baselineRef ?? process.env.GITDB_BENCH_BASE_REF ?? "HEAD~1" + const { stdout } = await execFileAsync("git", ["show", `${baselineRef}:${baselinePath}`], { + maxBuffer: 1024 * 1024, + }) + return { + source: `${baselineRef}:${baselinePath}`, + text: stdout, + } +} + +async function writeJson(path, value) { + await writeText(path, `${JSON.stringify(value, null, 2)}\n`) +} + +async function writeText(path, value) { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, value, "utf8") +} + +function requireValue(value, name) { + if (value === undefined || value.trim().length === 0) { + throw new Error(`${name} is required`) + } + return value +} diff --git a/scripts/benchmark-gate.mjs b/scripts/benchmark-gate.mjs new file mode 100644 index 0000000..d198d9d --- /dev/null +++ b/scripts/benchmark-gate.mjs @@ -0,0 +1,212 @@ +import { execFile } from "node:child_process" +import { readFile } from "node:fs/promises" +import { promisify } from "node:util" + +const execFileAsync = promisify(execFile) + +const CURRENT_REQUIRED_SCENARIOS = [ + "local-plaintext", + "local-encrypted", + "orm-save-single", + "orm-insert", + "orm-insert-many", + "orm-save-many", + "orm-indexed-lookup", + "reopen", + "compaction", +] + +const COMPLETE_REQUIRED_SCENARIOS = CURRENT_REQUIRED_SCENARIOS + +const THRESHOLDS = { + joinMs: 30_000, + reopenMs: 60_000, + writeMs: 120_000, + writesPerSecond: 0.01, +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error) + console.error(`benchmark gate failed: ${message}`) + process.exitCode = 1 +}) + +async function main() { + const options = parseArgs(process.argv.slice(2)) + const text = + options.input === undefined ? await runLocalBenchmark() : await readFile(options.input, "utf8") + const evidence = parseEvidence(text) + const required = + options.profile === "complete" ? COMPLETE_REQUIRED_SCENARIOS : CURRENT_REQUIRED_SCENARIOS + const rowsByKey = new Map(evidence.results.map((row) => [scenarioKey(row.label), row])) + const checked = [] + + for (const key of required) { + const row = rowsByKey.get(key) + if (row === undefined) { + throw new Error(`missing scenario: ${key}`) + } + checkThresholds(key, row) + checked.push(key) + } + + process.stdout.write( + [ + "benchmark gate passed", + `profile: ${options.profile}`, + `repeat count: ${evidence.metadata.repeatCount}`, + `checked scenarios: ${checked.join(", ")}`, + ].join("\n"), + ) + process.stdout.write("\n") +} + +function parseArgs(args) { + const options = { input: undefined, profile: "current" } + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === "--input") { + options.input = requiredValue(args, index, arg) + index += 1 + continue + } + if (arg === "--profile") { + options.profile = profileValue(requiredValue(args, index, arg)) + index += 1 + continue + } + throw new Error(`unknown argument: ${arg}`) + } + return options +} + +function requiredValue(args, index, flag) { + const value = args[index + 1] + if (value === undefined || value.startsWith("--")) { + throw new Error(`${flag} requires a value`) + } + return value +} + +function profileValue(value) { + if (value === "current" || value === "complete") { + return value + } + throw new Error("--profile must be current or complete") +} + +async function runLocalBenchmark() { + const { stdout } = await execFileAsync(process.execPath, ["scripts/benchmark.mjs", "--json"], { + env: process.env, + maxBuffer: 10 * 1024 * 1024, + }) + return stdout +} + +function parseEvidence(text) { + const parsed = JSON.parse(text) + const rows = benchmarkRows(parsed) + return { + metadata: Array.isArray(parsed) ? { repeatCount: 1 } : metadata(parsed.metadata), + results: rows.map((row) => normalizeRow(row)), + } +} + +function benchmarkRows(parsed) { + if (Array.isArray(parsed)) { + return parsed + } + if (typeof parsed !== "object" || parsed === null) { + throw new Error("benchmark JSON must be an array or contain a results array") + } + if (Array.isArray(parsed.results)) { + return parsed.results + } + if (Array.isArray(parsed.scenarios)) { + return parsed.scenarios + } + throw new Error("benchmark JSON must be an array or contain a results array") +} + +function metadata(value) { + if (value === undefined) { + return { repeatCount: 1 } + } + if (typeof value !== "object" || value === null) { + throw new Error("metadata must be an object") + } + const repeatCount = Number(value.repeatCount) + if (!Number.isInteger(repeatCount) || repeatCount <= 0) { + throw new Error("metadata.repeatCount must be a positive integer") + } + return { repeatCount } +} + +function normalizeRow(value) { + if (typeof value !== "object" || value === null) { + throw new Error("benchmark row must be an object") + } + return { + joinMs: positiveFiniteNumber(value.joinMs, "joinMs"), + label: label(value.label), + reopenMs: positiveFiniteNumber(value.reopenMs, "reopenMs"), + rows: positiveInteger(value.rows, "rows"), + writeMs: positiveFiniteNumber(value.writeMs, "writeMs"), + writesPerSecond: positiveFiniteNumber(value.writesPerSecond, "writesPerSecond"), + } +} + +function label(value) { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error("label must be a non-empty string") + } + return value +} + +function positiveInteger(value, field) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${field} must be a positive integer`) + } + return parsed +} + +function positiveFiniteNumber(value, field) { + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${field} must be a positive number`) + } + return parsed +} + +function scenarioKey(label) { + const normalized = label.toLowerCase() + if (normalized.includes("save()")) return "orm-save-single" + if (normalized.includes("insertmany") || normalized.includes("insert many")) + return "orm-insert-many" + if (normalized.includes("savemany") || normalized.includes("save many")) return "orm-save-many" + if (normalized.includes("insert()")) return "orm-insert" + if (normalized.includes("indexed")) return "orm-indexed-lookup" + if (normalized.includes("compaction")) return "compaction" + if (normalized.includes("reopen checkpoint")) return "reopen" + if (normalized.startsWith("local plaintext")) return "local-plaintext" + if (normalized.startsWith("local encrypted")) return "local-encrypted" + return normalized.replaceAll(/\s+/g, "-") +} + +function checkThresholds(key, row) { + if (row.writeMs > THRESHOLDS.writeMs) { + throw new Error(`${key} writeMs ${row.writeMs} exceeds ${THRESHOLDS.writeMs}`) + } + if (row.joinMs > THRESHOLDS.joinMs) { + throw new Error(`${key} joinMs ${row.joinMs} exceeds ${THRESHOLDS.joinMs}`) + } + if (row.reopenMs > THRESHOLDS.reopenMs) { + throw new Error(`${key} reopenMs ${row.reopenMs} exceeds ${THRESHOLDS.reopenMs}`) + } + if (row.writesPerSecond < THRESHOLDS.writesPerSecond) { + throw new Error( + `${key} writesPerSecond ${row.writesPerSecond} is below ${THRESHOLDS.writesPerSecond}`, + ) + } +} diff --git a/scripts/benchmark-report-format.mjs b/scripts/benchmark-report-format.mjs new file mode 100644 index 0000000..573208a --- /dev/null +++ b/scripts/benchmark-report-format.mjs @@ -0,0 +1,33 @@ +export function formatComparisonMarkdown(evidence) { + const lines = [ + "| Scenario | Previous writes/s | Current writes/s | Change | Write ms change | Join ms change |", + "| --- | ---: | ---: | ---: | ---: | ---: |", + ] + for (const item of evidence.comparisons) { + lines.push( + `| ${item.scenario} | ${fixed(item.baseline.writesPerSecond)} | ${fixed(item.current.writesPerSecond)} | ${formatPct(item.writesPerSecondChangePct)} | ${formatPct(item.writeMsChangePct)} | ${formatPct(item.joinMsChangePct)} |`, + ) + } + if (evidence.current.metadata !== null) { + lines.push("") + lines.push(formatRuntimeMetadata(evidence.current.metadata)) + } + return `${lines.join("\n")}\n` +} + +function formatPct(value) { + return value === null ? "n/a" : `${value >= 0 ? "+" : ""}${fixed(value)}%` +} + +function formatRuntimeMetadata(metadata) { + const node = metadata.node ?? "unknown node" + const platform = metadata.platform ?? "unknown platform" + const arch = metadata.arch ?? "unknown arch" + const repeat = metadata.repeatCount ?? "unknown" + const warmup = metadata.warmupCount ?? "unknown" + return `Runtime: ${node} on ${platform}/${arch}; repeat=${repeat}, warmup=${warmup}` +} + +function fixed(value) { + return value.toFixed(2) +} diff --git a/scripts/benchmark-report.mjs b/scripts/benchmark-report.mjs new file mode 100644 index 0000000..058a358 --- /dev/null +++ b/scripts/benchmark-report.mjs @@ -0,0 +1,258 @@ +export { formatComparisonMarkdown } from "./benchmark-report-format.mjs" + +export function parseBenchmarkOutput(text) { + return parseBenchmarkEvidence(text).results +} + +export function parseBenchmarkEvidence(text) { + const trimmed = text.trim() + if (trimmed.startsWith("[") || trimmed.startsWith("{")) { + return parseJsonEvidence(trimmed) + } + return { metadata: null, results: parseMarkdownResults(text) } +} + +export function buildBenchmarkEvidence({ + baseline, + baselineLabel, + baselineSource, + current, + currentLabel, + currentMetadata, + currentSource, +}) { + const baselineByKey = new Map() + for (const row of baseline) { + baselineByKey.set(scenarioKey(row.label), row) + } + + const comparisons = [] + for (const currentRow of current) { + const key = scenarioKey(currentRow.label) + const baselineRow = baselineByKey.get(key) + if (baselineRow !== undefined) { + comparisons.push(compareRows(key, baselineRow, currentRow)) + } + } + + if (comparisons.length === 0) { + throw new Error("no comparable benchmark rows found") + } + + return { + baseline: { + label: baselineLabel, + source: baselineSource, + }, + comparisons, + current: { + label: currentLabel, + metadata: currentMetadata ?? null, + source: currentSource, + }, + generatedAt: new Date().toISOString(), + headline: headline(comparisons), + scenarios: current.map((row) => ({ ...row, key: siteScenarioKey(row.label) })), + } +} + +function parseJsonEvidence(text) { + const parsed = JSON.parse(text) + const rows = jsonRows(parsed) + const metadata = Array.isArray(parsed) ? null : jsonMetadata(parsed.metadata) + return { metadata, results: rows.map((row) => normalizeResult(row)) } +} + +function jsonRows(parsed) { + const rows = Array.isArray(parsed) ? parsed : parsed.results + if (!Array.isArray(rows)) { + throw new Error("benchmark JSON must be an array or contain a results array") + } + return rows +} + +function jsonMetadata(value) { + if (value === undefined) { + return null + } + if (typeof value !== "object" || value === null) { + throw new Error("metadata must be an object") + } + return value +} + +function parseMarkdownResults(text) { + const results = [] + for (const line of text.split("\n")) { + if (!line.trim().startsWith("|")) { + continue + } + const cells = line + .split("|") + .slice(1, -1) + .map((cell) => cell.replaceAll("`", "").trim()) + if (cells.length < 6 || cells[0] === "Scenario" || cells[0].startsWith("---")) { + continue + } + const row = normalizeMarkdownRow(cells) + if (row !== undefined) { + results.push(row) + } + } + if (results.length === 0) { + throw new Error("no benchmark rows found") + } + return results +} + +function normalizeMarkdownRow(cells) { + const rows = parseFiniteNumber(cells[1]) + const writeMs = parseFiniteNumber(cells[2]) + const writesPerSecond = parseFiniteNumber(cells[3]) + const joinMs = parseFiniteNumber(cells[4]) + const reopenMs = parseFiniteNumber(cells[5]) + if ( + rows === undefined || + writeMs === undefined || + writesPerSecond === undefined || + joinMs === undefined || + reopenMs === undefined + ) { + return undefined + } + return { + joinMs, + label: cells[0], + reopenMs, + rows, + writeMs, + writesPerSecond, + } +} + +function normalizeResult(row) { + return { + joinMs: finiteNumber(row.joinMs, "joinMs"), + label: String(row.label), + reopenMs: finiteNumber(row.reopenMs, "reopenMs"), + rows: finiteNumber(row.rows, "rows"), + writeMs: finiteNumber(row.writeMs, "writeMs"), + writesPerSecond: finiteNumber(row.writesPerSecond, "writesPerSecond"), + } +} + +function compareRows(key, baselineRow, currentRow) { + return { + baseline: baselineRow, + current: currentRow, + joinMsChangePct: latencyChangePct(baselineRow.joinMs, currentRow.joinMs), + key, + reopenMsChangePct: latencyChangePct(baselineRow.reopenMs, currentRow.reopenMs), + rows: currentRow.rows, + scenario: currentRow.label, + writeMsChangePct: latencyChangePct(baselineRow.writeMs, currentRow.writeMs), + writeSpeedup: ratio(currentRow.writesPerSecond, baselineRow.writesPerSecond), + writesPerSecondChangePct: throughputChangePct( + baselineRow.writesPerSecond, + currentRow.writesPerSecond, + ), + } +} + +function scenarioKey(label) { + const normalized = label.toLowerCase() + if (normalized.includes("save()")) { + return "orm save single" + } + if (normalized.includes("insertmany") || normalized.includes("insert many")) { + return "orm insert many" + } + if (normalized.includes("savemany") || normalized.includes("save many")) { + return "orm save many" + } + if (normalized.includes("insert()")) { + return "orm insert" + } + if (normalized.includes("indexed")) { + return "orm indexed lookup" + } + if (normalized.startsWith("orm local plaintext")) { + return "orm indexed lookup" + } + if (normalized.includes("reopen checkpoint")) { + return "reopen" + } + if (normalized.includes("compaction")) { + return "compaction" + } + if (normalized.startsWith("local plaintext")) { + return "local plaintext" + } + if (normalized.startsWith("local encrypted")) { + return "local encrypted" + } + if (normalized.startsWith("github plaintext")) { + return "github plaintext" + } + return normalized +} + +function siteScenarioKey(label) { + return scenarioKey(label).replaceAll(/\s+/g, "-") +} + +function headline(comparisons) { + const orm = comparisons.find((row) => row.key === "orm save single") + const raw = comparisons.find((row) => row.key === "local plaintext") + if (orm !== undefined && raw !== undefined) { + const overhead = ratio(raw.current.writesPerSecond, orm.current.writesPerSecond) + return `Raw engine is ${formatRatio(overhead)} of ORM writes/s (save() transaction overhead)` + } + const localPlaintext = comparisons.find((row) => row.key === "local plaintext") + if (localPlaintext === undefined) { + return "Benchmark comparison generated" + } + return `Local plaintext writes are ${formatRatio(localPlaintext.writeSpeedup)} of the previous runtime baseline` +} + +function latencyChangePct(before, after) { + if (before <= 0) { + return null + } + return ((before - after) / before) * 100 +} + +function throughputChangePct(before, after) { + if (before <= 0) { + return null + } + return ((after - before) / before) * 100 +} + +function ratio(after, before) { + if (before <= 0) { + return null + } + return after / before +} + +function formatRatio(value) { + return value === null ? "n/a" : `${fixed(value)}x` +} + +function fixed(value) { + return value.toFixed(2) +} + +function parseFiniteNumber(value) { + const parsed = Number.parseFloat(value.replaceAll(",", "")) + return Number.isFinite(parsed) ? parsed : undefined +} + +function finiteNumber(value, field) { + const parsed = typeof value === "number" ? value : Number.parseFloat(String(value)) + if (!Number.isFinite(parsed)) { + throw new Error(`${field} must be a finite number`) + } + return parsed +} diff --git a/scripts/benchmark-scenarios.mjs b/scripts/benchmark-scenarios.mjs new file mode 100644 index 0000000..70dae2f --- /dev/null +++ b/scripts/benchmark-scenarios.mjs @@ -0,0 +1,267 @@ +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createAesGcmCipher } from "../dist/src/crypto/aes-gcm.js" +import { GitHubPlaintextStore } from "../dist/src/github/github-plaintext-store.js" +import { createGitDbDataSource, defineEntity } from "../dist/src/orm/index.js" +import { GitDbEngine } from "../dist/src/sql/engine.js" +import { LocalEncryptedStore } from "../dist/src/storage/local-encrypted-store.js" +import { LocalPlaintextStore } from "../dist/src/storage/local-plaintext-store.js" +import { requiredEnv, result, time } from "./benchmark-utils.mjs" + +const Team = defineEntity({ + columns: { id: "STRING", name: "STRING" }, + primaryKey: "id", + tableName: "teams", +}) + +const Person = defineEntity({ + columns: { id: "STRING", name: "STRING", team_id: "STRING" }, + indexes: [{ columns: ["team_id"], name: "people_team_id_idx" }], + primaryKey: "id", + tableName: "people", +}) + +export async function benchEngine({ encrypted = false, rows, snapshotPolicy }) { + return await withRoot("gitdb-bench-", async (root) => { + const store = encrypted ? encryptedStore(root) : new LocalPlaintextStore({ root }) + const engine = await GitDbEngine.open({ snapshotPolicy, store }) + await createTables(engine) + const writeMs = await time(async () => insertRowsInTransaction(engine, rows)) + const queryMs = await time(async () => assertJoin(engine, rows)) + const reopenMs = await time(async () => { + const reopened = await GitDbEngine.open({ snapshotPolicy, store }) + await assertJoin(reopened, rows) + }) + return result( + encrypted ? "local encrypted transaction batch" : "local plaintext transaction batch", + rows, + writeMs, + queryMs, + reopenMs, + ) + }) +} + +export async function benchOrmSaveSingle(options) { + return await benchOrmWrite({ + label: "orm save() single transactions", + options, + write: async ({ people, rows }) => { + for (const row of peopleRows(rows)) { + await people.save(row) + } + }, + }) +} + +export async function benchOrmInsertSingle(options) { + return await benchOrmWrite({ + label: "orm insert() single transactions", + options, + write: async ({ people, rows }) => { + for (const row of peopleRows(rows)) { + await people.insert(row) + } + }, + }) +} + +export async function benchOrmInsertMany(options) { + return await benchOrmWrite({ + label: "orm insertMany bulk transaction", + options, + write: async ({ people, rows }) => { + await people.insertMany(peopleRows(rows)) + }, + }) +} + +export async function benchOrmSaveMany(options) { + return await benchOrmWrite({ + label: "orm saveMany bulk transaction", + options, + write: async ({ people, rows }) => { + await people.saveMany(peopleRows(rows)) + }, + }) +} + +export async function benchOrmIndexedLookup(options) { + return await benchOrmWrite({ + label: "orm indexed equality lookup", + options, + write: async ({ people, rows }) => { + await people.insertMany(peopleRows(rows)) + }, + }) +} + +export async function benchReopen({ rows, snapshotPolicy }) { + return await withRoot("gitdb-reopen-bench-", async (root) => { + const store = new LocalPlaintextStore({ root }) + const engine = await GitDbEngine.open({ snapshotPolicy, store }) + await createTables(engine) + const writeMs = await time(async () => insertRowsInTransaction(engine, rows)) + const queryMs = await time(async () => assertJoin(engine, rows)) + const reopenMs = await time(async () => { + const reopened = await GitDbEngine.open({ snapshotPolicy, store }) + await assertJoin(reopened, rows) + }) + return result("local plaintext reopen checkpoint", rows, writeMs, queryMs, reopenMs) + }) +} + +export async function benchCompaction({ rows, snapshotPolicy }) { + return await withRoot("gitdb-compaction-bench-", async (root) => { + const store = new LocalPlaintextStore({ root }) + const engine = await GitDbEngine.open({ snapshotPolicy, store }) + await createTables(engine) + await insertRowsInTransaction(engine, rows) + const compactMs = await time(async () => { + const compacted = await engine.compact() + if (compacted.manifest.logSegments.length !== 0) { + throw new Error("expected compaction to clear manifest log segments") + } + }) + const queryMs = await time(async () => assertJoin(engine, rows)) + const reopenMs = await time(async () => { + const reopened = await GitDbEngine.open({ snapshotPolicy, store }) + await assertJoin(reopened, rows) + }) + return result("local plaintext compaction", rows, compactMs, queryMs, reopenMs) + }) +} + +export async function benchGitHubPlaintext(rowCount) { + const token = requiredEnv("GITDB_GITHUB_TOKEN") + const owner = requiredEnv("GITDB_GITHUB_OWNER") + const repo = requiredEnv("GITDB_GITHUB_REPO") + const branch = process.env.GITDB_GITHUB_BRANCH ?? "main" + const stamp = new Date().toISOString().replaceAll(/[:.]/g, "-") + const prefix = process.env.GITDB_BENCH_GITHUB_PREFIX ?? `gitdb/bench-${stamp}` + const store = new GitHubPlaintextStore({ branch, owner, prefix, repo, token }) + const engine = await GitDbEngine.open({ store }) + await createTables(engine) + const writeMs = await time(async () => insertRowsInTransaction(engine, rowCount)) + const queryMs = await time(async () => assertJoin(engine, rowCount)) + const reopenMs = await time(async () => { + const reopened = await GitDbEngine.open({ store }) + await assertJoin(reopened, rowCount) + }) + return result(`github plaintext tree commits (${prefix})`, rowCount, writeMs, queryMs, reopenMs) +} + +async function benchOrmWrite({ label, options, write }) { + return await withRoot("gitdb-orm-bench-", async (root) => { + const dataSource = await ormDataSource(root, options.snapshotPolicy, true) + const people = dataSource.getRepository(Person) + const writeMs = await time(async () => write({ people, rows: options.rows })) + const queryMs = await time(async () => assertIndexedLookup(people)) + const reopenMs = await time(async () => { + const reopened = await ormDataSource(root, options.snapshotPolicy, false) + await assertOrmJoin(reopened, options.rows) + }) + return result(label, options.rows, writeMs, queryMs, reopenMs) + }) +} + +async function ormDataSource(root, snapshotPolicy, synchronize) { + const dataSource = await createGitDbDataSource({ + entities: [Team, Person], + snapshotPolicy, + store: new LocalPlaintextStore({ root }), + synchronize, + }) + if (synchronize) { + await seedTeams(dataSource.getRepository(Team)) + } + return dataSource +} + +async function seedTeams(teams) { + await teams.insertMany( + Array.from({ length: 10 }, (_, index) => ({ id: `t${index}`, name: `Team ${index}` })), + ) +} + +async function createTables(target) { + await execute(target, "CREATE TABLE teams (id STRING, name STRING)") + await execute(target, "CREATE TABLE people (id STRING, name STRING, team_id STRING)") + for (let index = 0; index < 10; index += 1) { + await execute(target, `INSERT INTO teams VALUES ('t${index}', 'Team ${index}')`) + } +} + +async function insertRows(target, rowCount) { + for (let index = 0; index < rowCount; index += 1) { + await execute( + target, + `INSERT INTO people VALUES ('p${index}', 'Person ${index}', 't${index % 10}')`, + ) + } +} + +async function insertRowsInTransaction(engine, rowCount) { + await engine.transaction(async (transaction) => { + await insertRows(transaction, rowCount) + }) +} + +function peopleRows(rowCount) { + return Array.from({ length: rowCount }, (_, index) => ({ + id: `p${index}`, + name: `Person ${index}`, + team_id: `t${index % 10}`, + })) +} + +async function assertIndexedLookup(people) { + const rows = await people.find({ where: { team_id: "t1" } }) + if (rows.length === 0 || rows.some((row) => row.team_id !== "t1")) { + throw new Error("indexed lookup returned unexpected rows") + } +} + +async function assertOrmJoin(dataSource, rowCount) { + const rows = await dataSource.query( + "SELECT people.name AS person, teams.name AS team FROM people JOIN teams ON people.team_id = teams.id ORDER BY people.name", + ) + if (rows.length !== rowCount) { + throw new Error(`expected ${rowCount} joined rows, got ${rows.length}`) + } +} + +async function assertJoin(target, rowCount) { + const response = await execute( + target, + "SELECT people.name AS person, teams.name AS team FROM people JOIN teams ON people.team_id = teams.id ORDER BY people.name", + ) + const count = Array.isArray(response.rows) ? response.rows.length : response.rowCount + if (count !== rowCount) { + throw new Error(`expected ${rowCount} joined rows, got ${count}`) + } +} + +async function execute(target, sql) { + if ("execute" in target) { + return await target.execute(sql) + } + return await target.query(sql) +} + +async function withRoot(prefix, work) { + const root = await mkdtemp(join(tmpdir(), prefix)) + try { + return await work(root) + } finally { + await rm(root, { force: true, recursive: true }) + } +} + +function encryptedStore(root) { + return new LocalEncryptedStore({ + cipher: createAesGcmCipher(Buffer.alloc(32, 7).toString("base64url")), + root, + }) +} diff --git a/scripts/benchmark-utils.mjs b/scripts/benchmark-utils.mjs new file mode 100644 index 0000000..62dc0d6 --- /dev/null +++ b/scripts/benchmark-utils.mjs @@ -0,0 +1,84 @@ +import { mkdir, writeFile } from "node:fs/promises" +import { dirname } from "node:path" +import { performance } from "node:perf_hooks" + +export async function time(operation) { + const startedAt = performance.now() + await operation() + return performance.now() - startedAt +} + +export function result(label, rowCount, writeMs, joinMs, reopenMs) { + return { + joinMs, + label, + reopenMs, + rows: rowCount, + writesPerSecond: (rowCount / writeMs) * 1000, + writeMs, + } +} + +export function scenarioKey(label) { + const normalized = label.toLowerCase() + if (normalized.includes("savemany") || normalized.includes("save many")) return "orm-save-many" + if (normalized.includes("save()")) return "orm-save-single" + if (normalized.includes("insertmany") || normalized.includes("insert many")) + return "orm-insert-many" + if (normalized.includes("insert()")) return "orm-insert" + if (normalized.includes("indexed")) return "orm-indexed-lookup" + if (normalized.includes("compaction")) return "compaction" + if (normalized.includes("reopen checkpoint")) return "reopen" + if (normalized.startsWith("local plaintext")) return "local-plaintext" + if (normalized.startsWith("local encrypted")) return "local-encrypted" + if (normalized.startsWith("github plaintext")) return "github-plaintext" + return normalized.replaceAll(/\s+/g, "-") +} + +export function formatMarkdown(items, metadata) { + const lines = [ + "| Scenario | Rows | Write ms | Writes/s | Query ms | Reopen ms |", + "| --- | ---: | ---: | ---: | ---: | ---: |", + ] + for (const item of items) { + lines.push( + `| ${item.label} | ${item.rows} | ${fixed(item.writeMs)} | ${fixed(item.writesPerSecond)} | ${fixed(item.joinMs)} | ${fixed(item.reopenMs)} |`, + ) + } + if (metadata !== undefined) { + lines.push("") + lines.push( + `Runtime: ${metadata.node} on ${metadata.platform}/${metadata.arch}; repeat=${metadata.repeatCount}, warmup=${metadata.warmupCount}`, + ) + } + return `${lines.join("\n")}\n` +} + +export async function writeJson(path, value) { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8") +} + +export function numberEnv(name, defaultValue) { + const value = process.env[name] + if (value === undefined || value.trim().length === 0) { + return defaultValue + } + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer`) + } + return parsed +} + +export function requiredEnv(name) { + const value = process.env[name] + if (value === undefined || value.trim().length === 0) { + throw new Error(`${name} is required`) + } + return value +} + +function fixed(value) { + return value.toFixed(2) +} diff --git a/scripts/benchmark.mjs b/scripts/benchmark.mjs index c3c958f..a4e96b0 100644 --- a/scripts/benchmark.mjs +++ b/scripts/benchmark.mjs @@ -1,200 +1,109 @@ -import { mkdtemp, rm } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { performance } from "node:perf_hooks" -import { Client } from "pg" -import { createAesGcmCipher } from "../dist/src/crypto/aes-gcm.js" -import { GitHubPlaintextStore } from "../dist/src/github/github-plaintext-store.js" -import { createGitDbServer } from "../dist/src/protocol/postgres-server.js" -import { GitDbEngine } from "../dist/src/sql/engine.js" -import { LocalEncryptedStore } from "../dist/src/storage/local-encrypted-store.js" -import { LocalPlaintextStore } from "../dist/src/storage/local-plaintext-store.js" +import { arch, cpus, platform, release } from "node:os" +import { + benchCompaction, + benchEngine, + benchGitHubPlaintext, + benchOrmIndexedLookup, + benchOrmInsertMany, + benchOrmInsertSingle, + benchOrmSaveMany, + benchOrmSaveSingle, + benchReopen, +} from "./benchmark-scenarios.mjs" +import { formatMarkdown, numberEnv, result, scenarioKey, writeJson } from "./benchmark-utils.mjs" const args = new Set(process.argv.slice(2)) const rows = numberEnv("GITDB_BENCH_ROWS", 250) const githubRows = numberEnv("GITDB_BENCH_GITHUB_ROWS", 8) -const results = [] - -results.push( - await benchEngine({ - label: "local plaintext visible snapshots", - rows, - store: (root) => new LocalPlaintextStore({ root }), - }), -) -results.push( - await benchEngine({ - label: "local encrypted mutation log", - rows, - store: (root) => - new LocalEncryptedStore({ - cipher: createAesGcmCipher(Buffer.alloc(32, 7).toString("base64url")), - root, - }), - }), -) -results.push(await benchPostgresFacade(rows)) +const repeatCount = numberEnv("GITDB_BENCH_REPEAT", 1) +const warmupCount = numberEnv("GITDB_BENCH_WARMUP", 0) +const snapshotPolicy = { mode: "interval", mutations: 100 } + +const localScenarios = [ + () => benchEngine({ rows, snapshotPolicy }), + () => benchEngine({ encrypted: true, rows }), + () => benchOrmSaveSingle({ rows, snapshotPolicy }), + () => benchOrmInsertSingle({ rows, snapshotPolicy }), + () => benchOrmInsertMany({ rows, snapshotPolicy }), + () => benchOrmSaveMany({ rows, snapshotPolicy }), + () => benchOrmIndexedLookup({ rows, snapshotPolicy }), + () => benchReopen({ rows, snapshotPolicy }), + () => benchCompaction({ rows, snapshotPolicy }), +] + +const results = await runBenchmarkPlan(localScenarios) if (args.has("--github")) { results.push(await benchGitHubPlaintext(githubRows)) } -process.stdout.write(formatMarkdown(results)) - -async function benchEngine(options) { - const root = await mkdtemp(join(tmpdir(), "gitdb-bench-")) - try { - const engine = await GitDbEngine.open({ store: options.store(root) }) - await createTables(engine) - const writeMs = await time(async () => { - await insertRows(engine, options.rows) - }) - const joinMs = await time(async () => { - await assertJoin(engine, options.rows) - }) - const reopenMs = await time(async () => { - const reopened = await GitDbEngine.open({ store: options.store(root) }) - await assertJoin(reopened, options.rows) - }) - return result(options.label, options.rows, writeMs, joinMs, reopenMs) - } finally { - await rm(root, { force: true, recursive: true }) - } +const evidence = { + metadata: benchmarkMetadata(), + results, } -async function benchPostgresFacade(rowCount) { - const root = await mkdtemp(join(tmpdir(), "gitdb-pg-bench-")) - const store = new LocalEncryptedStore({ - cipher: createAesGcmCipher(Buffer.alloc(32, 9).toString("base64url")), - root, - }) - const engine = await GitDbEngine.open({ store }) - const server = await createGitDbServer({ engine, host: "127.0.0.1", port: 0 }) - const client = new Client({ - connectionString: `postgresql://127.0.0.1:${server.port}/main`, - }) - try { - await client.connect() - await createTables(client) - const writeMs = await time(async () => { - await insertRows(client, rowCount) - }) - const joinMs = await time(async () => { - await assertJoin(client, rowCount) - }) - return result("postgres facade over local encrypted", rowCount, writeMs, joinMs, 0) - } finally { - await client.end() - await server.close() - await rm(root, { force: true, recursive: true }) - } +if (process.env.GITDB_BENCH_OUTPUT !== undefined) { + await writeJson(process.env.GITDB_BENCH_OUTPUT, evidence) } -async function benchGitHubPlaintext(rowCount) { - const owner = requiredEnv("GITDB_GITHUB_OWNER") - const repo = requiredEnv("GITDB_GITHUB_REPO") - const token = requiredEnv("GITDB_GITHUB_TOKEN") - const branch = process.env.GITDB_GITHUB_BRANCH ?? "main" - const stamp = new Date().toISOString().replaceAll(/[:.]/g, "-") - const prefix = process.env.GITDB_BENCH_GITHUB_PREFIX ?? `gitdb/bench-${stamp}` - const store = new GitHubPlaintextStore({ branch, owner, prefix, repo, token }) - const engine = await GitDbEngine.open({ store }) - await createTables(engine) - const writeMs = await time(async () => { - await insertRows(engine, rowCount) - }) - const joinMs = await time(async () => { - await assertJoin(engine, rowCount) +if (process.env.GITDB_SITE_OUTPUT !== undefined) { + await writeJson(process.env.GITDB_SITE_OUTPUT, { + generatedAt: evidence.metadata.generatedAt, + metadata: evidence.metadata, + scenarios: results.map((row) => ({ ...row, key: scenarioKey(row.label) })), }) - const reopenMs = await time(async () => { - const reopened = await GitDbEngine.open({ store }) - await assertJoin(reopened, rowCount) - }) - return result(`github plaintext contents api (${prefix})`, rowCount, writeMs, joinMs, reopenMs) } -async function createTables(target) { - await execute(target, "CREATE TABLE teams (id STRING, name STRING)") - await execute(target, "CREATE TABLE people (id STRING, name STRING, team_id STRING)") - for (let i = 0; i < 10; i += 1) { - await execute(target, `INSERT INTO teams VALUES ('t${i}', 'Team ${i}')`) - } -} +process.stdout.write( + args.has("--json") + ? `${JSON.stringify(evidence, null, 2)}\n` + : formatMarkdown(results, evidence.metadata), +) -async function insertRows(target, rowCount) { - for (let i = 0; i < rowCount; i += 1) { - await execute(target, `INSERT INTO people VALUES ('p${i}', 'Person ${i}', 't${i % 10}')`) +async function runBenchmarkPlan(scenarios) { + const rows = [] + for (const scenario of scenarios) { + for (let index = 0; index < warmupCount; index += 1) { + await scenario() + } + rows.push(averageResults(await repeatScenario(scenario))) } + return rows } -async function assertJoin(target, rowCount) { - const response = await execute( - target, - "SELECT people.name AS person, teams.name AS team FROM people JOIN teams ON people.team_id = teams.id ORDER BY people.name", - ) - const count = Array.isArray(response.rows) ? response.rows.length : response.rowCount - if (count !== rowCount) { - throw new Error(`expected ${rowCount} joined rows, got ${count}`) +async function repeatScenario(scenario) { + const rows = [] + for (let index = 0; index < repeatCount; index += 1) { + rows.push(await scenario()) } + return rows } -async function execute(target, sql) { - if ("execute" in target) { - return await target.execute(sql) +function averageResults(rows) { + const first = rows[0] + if (first === undefined) { + throw new Error("repeat count must produce at least one benchmark row") } - return await target.query(sql) + const averagedWriteMs = average(rows.map((row) => row.writeMs)) + const averagedJoinMs = average(rows.map((row) => row.joinMs)) + const averagedReopenMs = average(rows.map((row) => row.reopenMs)) + return result(first.label, first.rows, averagedWriteMs, averagedJoinMs, averagedReopenMs) } -async function time(operation) { - const startedAt = performance.now() - await operation() - return performance.now() - startedAt +function average(values) { + return values.reduce((sum, value) => sum + value, 0) / values.length } -function result(label, rowCount, writeMs, joinMs, reopenMs) { +function benchmarkMetadata() { return { - joinMs, - label, - reopenMs, - rows: rowCount, - writesPerSecond: (rowCount / writeMs) * 1000, - writeMs, - } -} - -function formatMarkdown(items) { - const lines = [ - "| Scenario | Rows | Write ms | Writes/s | Join ms | Reopen ms |", - "| --- | ---: | ---: | ---: | ---: | ---: |", - ] - for (const item of items) { - lines.push( - `| ${item.label} | ${item.rows} | ${fixed(item.writeMs)} | ${fixed(item.writesPerSecond)} | ${fixed(item.joinMs)} | ${fixed(item.reopenMs)} |`, - ) - } - return `${lines.join("\n")}\n` -} - -function fixed(value) { - return value.toFixed(2) -} - -function numberEnv(name, fallback) { - const value = process.env[name] - if (value === undefined || value.trim().length === 0) { - return fallback - } - const parsed = Number.parseInt(value, 10) - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error(`${name} must be a positive integer`) - } - return parsed -} - -function requiredEnv(name) { - const value = process.env[name] - if (value === undefined || value.trim().length === 0) { - throw new Error(`${name} is required`) + arch: arch(), + cpu: cpus()[0]?.model ?? "unknown", + generatedAt: new Date().toISOString(), + node: process.version, + platform: platform(), + release: release(), + repeatCount, + rows, + warmupCount, } - return value } diff --git a/site/app.js b/site/app.js new file mode 100644 index 0000000..3294a55 --- /dev/null +++ b/site/app.js @@ -0,0 +1,195 @@ +const numberFormatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 2, + minimumFractionDigits: 0, +}) + +const emptyBenchmarkData = { + comparisons: [], + current: { metadata: null }, + scenarios: [], +} + +render(await loadBenchmark()) + +async function loadBenchmark() { + try { + const response = await fetch("./benchmark.json", { cache: "no-store" }) + if (!response.ok) { + return emptyBenchmarkData + } + return await response.json() + } catch { + return emptyBenchmarkData + } +} + +function render(data) { + const scenarios = data.scenarios ?? [] + const comparisons = data.comparisons ?? [] + const local = findScenario(scenarios, "local-plaintext") + const saveSingle = findScenario(scenarios, "orm-save-single") + const insertMany = findScenario(scenarios, "orm-insert-many") + const indexed = findScenario(scenarios, "orm-indexed-lookup") + setText("hero-raw-wps", formatNumber(local?.writesPerSecond)) + setText("hero-bulk-delta", formatRatio(insertMany?.writesPerSecond, saveSingle?.writesPerSecond)) + setText( + "hero-index-ms", + indexed?.joinMs === undefined ? "n/a" : `${formatNumber(indexed.joinMs)} ms`, + ) + setText("benchmark-meta", metadataText(data.current?.metadata)) + renderComparisons(comparisons) + renderRows(scenarios) + renderBars(scenarios) +} + +function renderComparisons(comparisons) { + const target = document.getElementById("comparison-grid") + if (target === null) { + return + } + target.replaceChildren() + for (const item of comparisons.slice(0, 3)) { + const node = document.createElement("article") + node.className = "comparison" + node.append(smallText(item.scenario)) + node.append(strongText(formatPercent(item.writesPerSecondChangePct))) + node.append(smallText(`previous ${formatNumber(item.baseline?.writesPerSecond)} w/s`)) + target.append(node) + } +} + +function renderRows(scenarios) { + const target = document.getElementById("benchmark-rows") + if (target === null) { + return + } + target.replaceChildren() + if (scenarios.length === 0) { + const row = document.createElement("tr") + row.append(tableCell("Benchmark evidence pending", "left")) + row.append(tableCell("n/a")) + row.append(tableCell("n/a")) + row.append(tableCell("n/a")) + row.append(tableCell("n/a")) + target.append(row) + return + } + for (const item of scenarios) { + const row = document.createElement("tr") + row.append(tableCell(item.label, "left")) + row.append(tableCell(formatNumber(item.writesPerSecond))) + row.append(tableCell(`${formatNumber(item.writeMs)} ms`)) + row.append(tableCell(`${formatNumber(item.joinMs)} ms`)) + row.append(tableCell(`${formatNumber(item.reopenMs)} ms`)) + target.append(row) + } +} + +function renderBars(scenarios) { + const target = document.getElementById("chart-bars") + if (target === null) { + return + } + target.replaceChildren() + const max = scenarios.reduce( + (value, scenario) => Math.max(value, scenario.writesPerSecond ?? 0), + 0, + ) + if (max === 0) { + target.append(smallText("Benchmark evidence pending.")) + return + } + for (const item of scenarios) { + target.append(barRow(item, max)) + } +} + +function barRow(item, max) { + const row = document.createElement("div") + row.className = "bar-row" + + const label = document.createElement("div") + label.className = "bar-label" + label.append(smallText(item.label)) + label.append(smallText(`${formatNumber(item.writesPerSecond)} w/s`)) + + const track = document.createElement("div") + track.className = "bar-track" + track.append(bar(item.key, item.writesPerSecond, max)) + + row.append(label) + row.append(track) + return row +} + +function bar(key, value, max) { + const element = document.createElement("div") + element.className = `bar ${barTone(key)}` + element.style.width = `${Math.max(4, (value / max) * 100)}%` + return element +} + +function barTone(key) { + if (key === "orm-insert-many" || key === "orm-indexed-lookup") { + return "accent" + } + if (key === "orm-save-single" || key === "orm-insert") { + return "slow" + } + return "current" +} + +function tableCell(value, align = "right") { + const cell = document.createElement("td") + cell.textContent = value + if (align === "left") { + cell.style.textAlign = "left" + } + return cell +} + +function smallText(value) { + const element = document.createElement("span") + element.textContent = value + return element +} + +function strongText(value) { + const element = document.createElement("strong") + element.textContent = value + return element +} + +function setText(id, value) { + const element = document.getElementById(id) + if (element !== null) { + element.textContent = value + } +} + +function findScenario(scenarios, key) { + return scenarios.find((scenario) => scenario.key === key) +} + +function metadataText(metadata) { + if (metadata === null || metadata === undefined) { + return "Benchmark metadata unavailable." + } + return `${metadata.rows} rows, ${metadata.node}, ${metadata.platform}/${metadata.arch}, repeat ${metadata.repeatCount}, warmup ${metadata.warmupCount}.` +} + +function formatNumber(value) { + return typeof value === "number" ? numberFormatter.format(value) : "n/a" +} + +function formatRatio(after, before) { + return typeof after === "number" && typeof before === "number" && before > 0 + ? `${numberFormatter.format(after / before)}x` + : "n/a" +} + +function formatPercent(value) { + return typeof value === "number" + ? `${value >= 0 ? "+" : ""}${numberFormatter.format(value)}%` + : "n/a" +} diff --git a/site/assets/runtime-map.svg b/site/assets/runtime-map.svg new file mode 100644 index 0000000..a10ca30 --- /dev/null +++ b/site/assets/runtime-map.svg @@ -0,0 +1,56 @@ + + GitDB runtime architecture map + Application code talks to a local GitDB runtime, which writes transaction logs and visible snapshots before syncing durable state to a GitHub repository. + + + GitDB Runtime + local hot path with auditable GitHub storage + + + + App code + ORM repository + SQL engine API + + + + + + + + Local engine + serialized transactions + query execution + snapshot checkpoints + + + + + + + + WAL + visible snapshot + mutation log, manifest, table snapshots + rollback-aware persistence boundary + + + + + + + + GitHub + remote + durable + auditable + + + + + Why faster + + + + less snapshot rewrite work + + diff --git a/site/benchmark.json b/site/benchmark.json new file mode 100644 index 0000000..3478208 --- /dev/null +++ b/site/benchmark.json @@ -0,0 +1,186 @@ +{ + "baseline": { + "label": "previous runtime baseline", + "source": "docs/BENCHMARK_BASELINE.json" + }, + "comparisons": [ + { + "baseline": { + "joinMs": 32.97, + "label": "local plaintext visible snapshots", + "reopenMs": 140.05, + "rows": 250, + "writeMs": 1443.9, + "writesPerSecond": 173.14 + }, + "current": { + "joinMs": 8.939374999999927, + "label": "local plaintext transaction batch", + "reopenMs": 52.16433300000017, + "rows": 250, + "writeMs": 1136.0629589999999, + "writesPerSecond": 220.05822654411537 + }, + "joinMsChangePct": 72.88633606308788, + "key": "local plaintext", + "reopenMsChangePct": 62.753064619778534, + "rows": 250, + "scenario": "local plaintext transaction batch", + "writeMsChangePct": 21.31983108248495, + "writeSpeedup": 1.2709843279664745, + "writesPerSecondChangePct": 27.098432796647447 + }, + { + "baseline": { + "joinMs": 4.31, + "label": "local encrypted mutation log", + "reopenMs": 322.51, + "rows": 250, + "writeMs": 761.99, + "writesPerSecond": 328.09 + }, + "current": { + "joinMs": 1.0283750000007785, + "label": "local encrypted transaction batch", + "reopenMs": 93.31183300000066, + "rows": 250, + "writeMs": 2389.136959, + "writesPerSecond": 104.64029659674274 + }, + "joinMsChangePct": 76.1397911832766, + "key": "local encrypted", + "reopenMsChangePct": 71.06699544200158, + "rows": 250, + "scenario": "local encrypted transaction batch", + "writeMsChangePct": -213.5391486764918, + "writeSpeedup": 0.3189377810867224, + "writesPerSecondChangePct": -68.10622189132775 + }, + { + "baseline": { + "joinMs": 1.3, + "label": "orm local plaintext indexed lookup", + "reopenMs": 136.29, + "rows": 250, + "writeMs": 5377.34, + "writesPerSecond": 46.49 + }, + "current": { + "joinMs": 0.060459000000264496, + "label": "orm indexed equality lookup", + "reopenMs": 45.7231249999968, + "rows": 250, + "writeMs": 1015.2725830000018, + "writesPerSecond": 246.23929000552906 + }, + "joinMsChangePct": 95.34930769228734, + "key": "orm indexed lookup", + "reopenMsChangePct": 66.45159219311996, + "rows": 250, + "scenario": "orm indexed equality lookup", + "writeMsChangePct": 81.1194273934696, + "writeSpeedup": 5.296607657679695, + "writesPerSecondChangePct": 429.66076576796956 + } + ], + "current": { + "label": "current working tree", + "metadata": { + "arch": "arm64", + "cpu": "Apple M5", + "generatedAt": "2026-06-15T01:19:50.686Z", + "node": "v24.11.0", + "platform": "darwin", + "release": "25.5.0", + "repeatCount": 1, + "rows": 250, + "warmupCount": 0 + }, + "source": ".gitdb/bench-current.json" + }, + "generatedAt": "2026-06-15T01:19:50.749Z", + "headline": "Local plaintext writes are 1.27x of the previous runtime baseline", + "scenarios": [ + { + "joinMs": 8.939374999999927, + "label": "local plaintext transaction batch", + "reopenMs": 52.16433300000017, + "rows": 250, + "writeMs": 1136.0629589999999, + "writesPerSecond": 220.05822654411537, + "key": "local-plaintext" + }, + { + "joinMs": 1.0283750000007785, + "label": "local encrypted transaction batch", + "reopenMs": 93.31183300000066, + "rows": 250, + "writeMs": 2389.136959, + "writesPerSecond": 104.64029659674274, + "key": "local-encrypted" + }, + { + "joinMs": 0.27866700000049605, + "label": "orm save() single transactions", + "reopenMs": 95.09941599999911, + "rows": 250, + "writeMs": 10266.896334000001, + "writesPerSecond": 24.350104634065158, + "key": "orm-save-single" + }, + { + "joinMs": 0.3132499999992433, + "label": "orm insert() single transactions", + "reopenMs": 145.7625420000004, + "rows": 250, + "writeMs": 10403.6615, + "writesPerSecond": 24.030001360578677, + "key": "orm-insert" + }, + { + "joinMs": 0.05662500000107684, + "label": "orm insertMany bulk transaction", + "reopenMs": 44.37445799999841, + "rows": 250, + "writeMs": 1868.581833999997, + "writesPerSecond": 133.79130389212614, + "key": "orm-insert-many" + }, + { + "joinMs": 0.05783299999893643, + "label": "orm saveMany bulk transaction", + "reopenMs": 82.01083300000028, + "rows": 250, + "writeMs": 2459.1402080000007, + "writesPerSecond": 101.66154788031506, + "key": "orm-save-many" + }, + { + "joinMs": 0.060459000000264496, + "label": "orm indexed equality lookup", + "reopenMs": 45.7231249999968, + "rows": 250, + "writeMs": 1015.2725830000018, + "writesPerSecond": 246.23929000552906, + "key": "orm-indexed-lookup" + }, + { + "joinMs": 0.9968750000043656, + "label": "local plaintext reopen checkpoint", + "reopenMs": 153.98379199999908, + "rows": 250, + "writeMs": 1517.7509160000009, + "writesPerSecond": 164.71741006018925, + "key": "reopen" + }, + { + "joinMs": 0.5746670000007725, + "label": "local plaintext compaction", + "reopenMs": 21.13479200000438, + "rows": 250, + "writeMs": 103.83299999999872, + "writesPerSecond": 2407.7123843094496, + "key": "compaction" + } + ] +} diff --git a/site/benchmark.md b/site/benchmark.md new file mode 100644 index 0000000..f78bd57 --- /dev/null +++ b/site/benchmark.md @@ -0,0 +1,7 @@ +| Scenario | Previous writes/s | Current writes/s | Change | Write ms change | Join ms change | +| --- | ---: | ---: | ---: | ---: | ---: | +| local plaintext transaction batch | 173.14 | 220.06 | +27.10% | +21.32% | +72.89% | +| local encrypted transaction batch | 328.09 | 104.64 | -68.11% | -213.54% | +76.14% | +| orm indexed equality lookup | 46.49 | 246.24 | +429.66% | +81.12% | +95.35% | + +Runtime: v24.11.0 on darwin/arm64; repeat=1, warmup=0 diff --git a/site/favicon.svg b/site/favicon.svg new file mode 100644 index 0000000..f9dbfec --- /dev/null +++ b/site/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..d4c68df --- /dev/null +++ b/site/index.html @@ -0,0 +1,228 @@ + + + + + + GitDB - GitHub repo plus database runtime + + + + + +
+ + G + GitDB + + +
+ +
+
+ +
+

GitHub Repo + DB Runtime

+

GitDB

+

+ A local transaction runtime, repository API, page snapshots, + equality indexes, and compaction layer that uses GitHub repositories + as durable audit storage. +

+ +
+
+
Local batch writes/s
+
loading
+
+
+
Bulk vs save()
+
loading
+
+
+
Indexed lookup
+
loading
+
+
+
+
+ +
+
+

Guarantee Matrix

+

Storage-engine boundaries are explicit.

+

+ GitDB is scoped as a single-writer local runtime with durable, + auditable repository sync. Derived data can be rebuilt; committed + logs and manifests are the source of truth. +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CapabilityCurrent guaranteeBoundary
TransactionsSerialized mutations become visible only after manifest advance.In-process queue plus store commit boundary
ConcurrencyLocal single writer with stale lock recovery and multiple readers.Not distributed multi-writer OLTP
DurabilityManifest-gated replay with checkpoint validation on reopen.Manifest sequence and committed log segments
SnapshotsPlaintext page snapshots accelerate reopen and review.Optimization; logs remain authoritative
IndexesEquality indexes rebuild from committed rows and are ignored when stale.Derived data for safe equality lookups
CompactionCheckpoint first, manifest advance second, obsolete log deletion last.Local plaintext store
GitHub syncRemote writes use Git tree commits and non-force ref updates.Audit storage, not SELECT hot path
+
+
+ +
+
+

Measured Performance

+

Benchmarks include the slow path and the intended bulk path.

+

+ Loading benchmark metadata. +

+
+
+
+
+
+
+ slower + faster +
+
+
+
+ + + + + + + + + + + +
ScenarioWrites/sWrite msQuery msReopen ms
+
+
+
+ +
+
+

Runtime Shape

+

GitHub stores history; GitDB owns the database runtime.

+
+
+
+ 01 +

Repository API

+

Entities, repositories, `insertMany()`, `saveMany()`, and direct SQL queries.

+
+
+ 02 +

Local engine

+

SQL execution, serialized mutations, equality indexes, and reopen snapshots.

+
+
+ 03 +

Storage boundary

+

Manifest-gated mutation logs, page snapshots, compaction, and encrypted files.

+
+
+ 04 +

Remote audit

+

Git tree commits sync durable state without putting queries on the network path.

+
+
+
+ +
+
+

First-Party ORM

+

Use repositories for app code and bulk methods for throughput.

+
+
import { LocalPlaintextStore, createGitDbDataSource, defineEntity } from "@3xhaust/gitdb"
+
+const Person = defineEntity({
+  tableName: "people",
+  primaryKey: "id",
+  columns: { id: "STRING", name: "STRING", team_id: "STRING" },
+  indexes: [{ name: "people_team_id_idx", columns: ["team_id"] }],
+})
+
+const dataSource = await createGitDbDataSource({
+  entities: [Person],
+  store: new LocalPlaintextStore({ root: ".gitdb" }),
+  synchronize: true,
+})
+
+const people = dataSource.getRepository(Person)
+await people.insertMany([{ id: "p1", name: "Ada", team_id: "runtime" }])
+await people.find({ where: { team_id: "runtime" } })
+
+
+ + + + + + diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..1a77d7b --- /dev/null +++ b/site/styles.css @@ -0,0 +1,600 @@ +:root { + color: #111514; + background: #f6f7f2; + font-family: + "IBM Plex Sans", "Avenir Next", "Sohne", ui-sans-serif, system-ui, -apple-system, sans-serif; + font-synthesis: none; + letter-spacing: 0; + text-rendering: optimizeLegibility; + --ink: #111514; + --muted: #5c6662; + --paper: #f6f7f2; + --panel: #ffffff; + --line: #cbd6d0; + --green: #0f6b5f; + --rust: #d85a3a; + --blue: #2d4f7c; + --gold: #bc902d; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--paper); + color: var(--ink); +} + +a { + color: inherit; + text-decoration: none; +} + +code, +pre { + font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; +} + +.topbar { + position: relative; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 68px; + padding: 12px 28px; + border-bottom: 1px solid rgba(17, 21, 20, 0.16); + background: rgba(246, 247, 242, 0.94); + backdrop-filter: blur(16px); +} + +.brand, +.nav, +.actions, +.metric-strip, +.benchmark-layout, +.comparison-grid, +.runtime-rail, +footer { + display: flex; + align-items: center; +} + +.brand { + gap: 10px; + font-size: 18px; + font-weight: 800; +} + +.brand-mark { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 7px; + background: var(--ink); + color: var(--paper); +} + +.nav { + gap: 22px; + color: var(--muted); + font-size: 14px; + font-weight: 680; +} + +footer a:hover { + color: var(--green); +} + +.nav a:hover { + color: var(--green); +} + +main { + overflow: hidden; +} + +.hero { + position: relative; + min-height: min(740px, calc(100svh - 92px)); + padding: 74px 28px 58px; + overflow: hidden; + border-bottom: 1px solid rgba(17, 21, 20, 0.16); + background: #101716; + color: #f7f7f0; +} + +.hero::after { + position: absolute; + inset: 0; + content: ""; + background: rgba(16, 23, 22, 0.72); +} + +.hero-map { + position: absolute; + inset: 0 0 0 auto; + width: min(960px, 72vw); + height: 100%; + object-fit: cover; + opacity: 0.72; +} + +.hero-inner { + position: relative; + z-index: 1; + max-width: 1220px; + margin: 0 auto; +} + +.eyebrow { + margin: 0 0 14px; + color: #8dd2c7; + font-size: 13px; + font-weight: 820; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + letter-spacing: 0; +} + +h1 { + max-width: 780px; + margin: 0; + font-size: clamp(56px, 9vw, 118px); + line-height: 0.9; +} + +h2 { + max-width: 820px; + margin: 0; + font-size: clamp(30px, 4vw, 54px); + line-height: 1.02; +} + +h3 { + margin: 0 0 10px; + font-size: 18px; +} + +p { + color: var(--muted); + line-height: 1.65; +} + +.lede { + max-width: 720px; + margin: 24px 0 0; + color: #dce8e2; + font-size: 20px; +} + +.actions { + flex-wrap: wrap; + gap: 12px; + margin-top: 34px; +} + +.button { + min-height: 46px; + padding: 12px 18px; + border: 1px solid rgba(247, 247, 240, 0.42); + border-radius: 7px; + font-weight: 800; +} + +.button.primary { + border-color: #f7f7f0; + background: #f7f7f0; + color: var(--ink); +} + +.button.secondary { + background: rgba(15, 107, 95, 0.8); + color: #f7f7f0; +} + +.button.ghost { + color: #f7f7f0; +} + +.metric-strip { + align-items: stretch; + max-width: 860px; + margin: 42px 0 0; + padding: 0; + border: 1px solid rgba(247, 247, 240, 0.28); + border-radius: 8px; + background: rgba(247, 247, 240, 0.08); +} + +.metric-strip div { + flex: 1; + min-width: 0; + padding: 18px; + border-right: 1px solid rgba(247, 247, 240, 0.22); +} + +.metric-strip div:last-child { + border-right: 0; +} + +dt { + color: #b9c9c2; + font-size: 12px; + font-weight: 780; + text-transform: uppercase; +} + +dd { + margin: 8px 0 0; + color: #ffffff; + font-size: 26px; + font-weight: 860; +} + +.guarantees, +.benchmark-band, +.runtime, +.api-band { + padding: 70px 28px; +} + +.section-heading { + max-width: 1220px; + margin: 0 auto 30px; +} + +.section-heading p:last-child { + max-width: 760px; + margin-bottom: 0; +} + +.matrix-wrap, +.table-wrap { + max-width: 1220px; + margin: 0 auto; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +table { + width: 100%; + min-width: 760px; + border-collapse: collapse; +} + +th, +td { + padding: 16px 18px; + border-bottom: 1px solid var(--line); + text-align: right; + vertical-align: top; +} + +th { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} + +td:first-child, +th:first-child, +.matrix-table td, +.matrix-table th { + text-align: left; +} + +tbody tr:last-child td { + border-bottom: 0; +} + +.benchmark-band { + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: #e7eef0; +} + +.comparison-grid { + align-items: stretch; + gap: 14px; + max-width: 1220px; + margin: 0 auto 22px; +} + +.comparison { + flex: 1; + min-width: 0; + padding: 18px; + border-left: 4px solid var(--green); + background: var(--panel); +} + +.comparison span { + display: block; + color: var(--muted); + font-size: 13px; +} + +.comparison strong { + display: block; + margin: 8px 0; + color: var(--ink); + font-size: 30px; +} + +.benchmark-layout { + align-items: stretch; + gap: 22px; + max-width: 1220px; + margin: 0 auto; +} + +.chart-panel { + flex: 0 0 370px; + min-height: 360px; + padding: 22px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} + +.chart-axis { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 12px; + font-weight: 780; + text-transform: uppercase; +} + +.chart-bars { + display: grid; + gap: 17px; + margin-top: 24px; +} + +.bar-row { + display: grid; + gap: 8px; +} + +.bar-label { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 13px; + font-weight: 760; +} + +.bar-track { + height: 16px; + overflow: hidden; + border-radius: 4px; + background: #d8e0dd; +} + +.bar { + min-width: 4px; + height: 16px; +} + +.bar.current { + background: var(--blue); +} + +.bar.accent { + background: var(--green); +} + +.bar.slow { + background: var(--rust); +} + +.runtime-rail { + align-items: stretch; + gap: 0; + max-width: 1220px; + margin: 0 auto; + border-top: 1px solid var(--ink); + border-bottom: 1px solid var(--ink); +} + +.runtime-rail article { + flex: 1; + min-width: 0; + padding: 24px; + border-right: 1px solid var(--line); +} + +.runtime-rail article:last-child { + border-right: 0; +} + +.runtime-rail span { + display: block; + margin-bottom: 28px; + color: var(--rust); + font-weight: 860; +} + +.api-band { + max-width: 1220px; + margin: 0 auto; +} + +.api-band pre { + overflow-x: auto; + margin: 0; + padding: 24px; + border-radius: 8px; + background: var(--ink); + color: #f6f7f2; + font-size: 14px; + line-height: 1.6; +} + +footer { + justify-content: center; + flex-wrap: wrap; + gap: 22px; + min-height: 88px; + padding: 22px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 14px; +} + +footer .footer-brand { + color: var(--ink); + font-weight: 840; +} + +@media (max-width: 940px) { + .topbar { + align-items: flex-start; + flex-direction: column; + gap: 14px; + } + + .nav { + flex-wrap: wrap; + gap: 14px; + } + + .hero { + min-height: auto; + padding-top: 52px; + } + + .hero-map { + width: 100%; + opacity: 0.38; + } + + .metric-strip, + .benchmark-layout, + .comparison-grid, + .runtime-rail { + flex-direction: column; + } + + .metric-strip div { + border-right: 0; + border-bottom: 1px solid rgba(247, 247, 240, 0.22); + } + + .metric-strip div:last-child { + border-bottom: 0; + } + + .runtime-rail article { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .runtime-rail article:last-child { + border-bottom: 0; + } + + .chart-panel { + flex-basis: auto; + } +} + +@media (max-width: 580px) { + .topbar, + .hero, + .guarantees, + .benchmark-band, + .runtime, + .api-band { + padding-right: 18px; + padding-left: 18px; + } + + .button, + .actions { + width: 100%; + } + + .button { + text-align: center; + } + + .lede { + font-size: 18px; + } + + dd { + font-size: 22px; + } + + .comparison strong { + font-size: 26px; + } + + .matrix-table { + min-width: 0; + } + + .matrix-table thead { + display: none; + } + + .matrix-table, + .matrix-table tbody, + .matrix-table tr, + .matrix-table td { + display: block; + width: 100%; + } + + .matrix-table tr { + padding: 14px 0; + border-bottom: 1px solid var(--line); + } + + .matrix-table tr:last-child { + border-bottom: 0; + } + + .matrix-table td { + padding: 6px 16px; + border-bottom: 0; + } + + .matrix-table td:first-child { + color: var(--green); + font-weight: 840; + } + + .matrix-table td:nth-child(2)::before { + display: block; + margin-bottom: 4px; + color: var(--muted); + content: "Guarantee"; + font-size: 11px; + font-weight: 820; + text-transform: uppercase; + } + + .matrix-table td:nth-child(3)::before { + display: block; + margin-bottom: 4px; + color: var(--muted); + content: "Boundary"; + font-size: 11px; + font-weight: 820; + text-transform: uppercase; + } +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 4434c0a..4e16554 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import { Command } from "commander" import pino from "pino" -import { createGitDbServer } from "../protocol/postgres-server.js" import { GitDbEngine } from "../sql/engine.js" import { generateKey } from "./keygen.js" import { createStoreFromEnv } from "./store-factory.js" @@ -10,7 +9,7 @@ const logger = pino({ level: process.env["LOG_LEVEL"] ?? "info" }) const program = new Command() .name("gitdb") - .description("GitHub-native encrypted database with a PostgreSQL-compatible facade") + .description("GitHub-native database runtime with local execution and auditable storage") .version("0.1.0") program @@ -21,24 +20,14 @@ program }) program - .command("serve") - .description("Start the PostgreSQL-compatible facade") - .option("--host ", "host to bind") - .option("--port ", "port to bind") - .action(async (options: { readonly host?: string; readonly port?: string }) => { - const { env, mode, store } = createStoreFromEnv(process.env) + .command("query") + .description("Execute one GitDB SQL statement against the configured store") + .argument("", "SQL statement") + .action(async (sqlParts: readonly string[]) => { + const { store } = createStoreFromEnv(process.env) const engine = await GitDbEngine.open({ store }) - const port = options.port === undefined ? env.GITDB_PORT : Number.parseInt(options.port, 10) - const host = options.host ?? env.GITDB_HOST - const server = await createGitDbServer({ engine, host, port }) - logger.info( - { - host: server.host, - mode, - port: server.port, - }, - "gitdb postgres facade listening", - ) + const result = await engine.execute(sqlParts.join(" ")) + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) }) program @@ -60,7 +49,7 @@ async function main(): Promise { main().catch((error: unknown) => { if (error instanceof Error) { logger.error({ error: error.message, stack: error.stack }, "gitdb failed") - process.stderr.write(`${error.stack ?? error.message}\n`) + process.stderr.write(`${error.name}: ${error.message}\n`) process.exitCode = 1 return } diff --git a/src/config/env.ts b/src/config/env.ts index 7e4c2cb..76b3f55 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -5,8 +5,6 @@ const EnvSchema = z.object({ GITDB_ENCRYPTION: z.enum(["on", "off"]).default("on"), GITDB_KEY: z.string().min(1).optional(), GITDB_ROOT: z.string().default(".gitdb"), - GITDB_HOST: z.string().default("127.0.0.1"), - GITDB_PORT: z.coerce.number().int().min(0).max(65_535).default(7432), }) export type GitDbEnv = z.infer diff --git a/src/errors.ts b/src/errors.ts index 804a806..0fcde0e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -7,7 +7,11 @@ export class CryptoKeyError extends Error { } export class GitDbStorageError extends Error { - readonly name = "GitDbStorageError" + readonly name: string = "GitDbStorageError" +} + +export class GitDbLockTimeoutError extends GitDbStorageError { + readonly name = "GitDbLockTimeoutError" } export class SqlExecutionError extends Error { @@ -32,3 +36,7 @@ export class UnsupportedSqlError extends Error { export class ProtocolError extends Error { readonly name = "ProtocolError" } + +export class GitDbOrmError extends Error { + readonly name = "GitDbOrmError" +} diff --git a/src/github/errors.ts b/src/github/errors.ts index 942f6d4..645fe58 100644 --- a/src/github/errors.ts +++ b/src/github/errors.ts @@ -35,7 +35,7 @@ export function gitHubWriteError( `GitHub database repo ${config.owner}/${config.repo}@${config.branch} is not writable with GITDB_GITHUB_TOKEN while writing ${path}.`, "Check that the repository exists, the branch exists, and the token is granted to this repository with Contents: Read and write.", "GitHub returns 404 for private or unauthorized repositories.", - "For local Express + Prisma example mode, leave GITDB_GITHUB_TOKEN blank.", + "For local-only example mode, leave GITDB_GITHUB_TOKEN blank.", ].join(" "), ) } diff --git a/src/github/git-database-client.ts b/src/github/git-database-client.ts new file mode 100644 index 0000000..54e18e7 --- /dev/null +++ b/src/github/git-database-client.ts @@ -0,0 +1,223 @@ +import { Octokit } from "@octokit/rest" +import { z } from "zod" +import { GitDbStorageError } from "../errors.js" +import { githubStatus, isGitHubConflict, isGitHubTransient } from "./errors.js" +import type { GitHubConfig } from "./types.js" + +const GITHUB_API_VERSION = "2022-11-28" +const DEFAULT_FILE_MODE = "100644" +const DEFAULT_TREE_TYPE = "blob" + +const GitRefResponseSchema = z.object({ + object: z.object({ + sha: z.string().min(1), + }), +}) + +const GitCommitResponseSchema = z.object({ + tree: z.object({ + sha: z.string().min(1), + }), +}) + +const GitShaResponseSchema = z.object({ + sha: z.string().min(1), +}) + +const GitHubErrorContextSchema = z.object({ + response: z + .object({ + headers: z.record(z.string(), z.unknown()).optional(), + }) + .optional(), + status: z.number().int(), +}) + +export type GitHubTreeEntry = { + readonly content: string + readonly mode?: typeof DEFAULT_FILE_MODE + readonly path: string + readonly type?: typeof DEFAULT_TREE_TYPE +} + +export type GitHubTreeCommitInput = { + readonly entries: readonly GitHubTreeEntry[] + readonly message: string +} + +export type GitHubTreeCommitResult = { + readonly baseCommitSha: string + readonly commitSha: string + readonly treeSha: string +} + +export class GitHubGitDatabaseConflictError extends GitDbStorageError { + readonly name: string = "GitHubGitDatabaseConflictError" +} + +export class GitHubGitDatabaseTransientError extends GitDbStorageError { + readonly name: string = "GitHubGitDatabaseTransientError" +} + +export class GitHubGitDatabaseRateLimitError extends GitHubGitDatabaseTransientError { + readonly name: string = "GitHubGitDatabaseRateLimitError" + + constructor( + message: string, + readonly retryAfterSeconds: number | null, + ) { + super(message) + } +} + +export class GitHubGitDatabaseClient { + readonly #config: GitHubConfig + readonly #octokit: Octokit + + constructor(config: GitHubConfig) { + this.#config = config + this.#octokit = new Octokit({ + auth: config.token, + request: { + headers: { + "X-GitHub-Api-Version": GITHUB_API_VERSION, + }, + }, + }) + } + + async commitTree(input: GitHubTreeCommitInput): Promise { + try { + const baseCommitSha = await this.#readBaseCommitSha() + const baseTreeSha = await this.#readBaseTreeSha(baseCommitSha) + const treeSha = await this.#createTree(baseTreeSha, input.entries) + const commitSha = await this.#createCommit(input.message, treeSha, baseCommitSha) + await this.#updateBranch(commitSha) + return { baseCommitSha, commitSha, treeSha } + } catch (error) { + throw classifyGitDatabaseError(this.#config, error) + } + } + + async #readBaseCommitSha(): Promise { + const response = await this.#octokit.git.getRef({ + owner: this.#config.owner, + ref: `heads/${this.#config.branch}`, + repo: this.#config.repo, + }) + return GitRefResponseSchema.parse(response.data).object.sha + } + + async #readBaseTreeSha(baseCommitSha: string): Promise { + const response = await this.#octokit.git.getCommit({ + commit_sha: baseCommitSha, + owner: this.#config.owner, + repo: this.#config.repo, + }) + return GitCommitResponseSchema.parse(response.data).tree.sha + } + + async #createTree(baseTreeSha: string, entries: readonly GitHubTreeEntry[]): Promise { + const response = await this.#octokit.git.createTree({ + base_tree: baseTreeSha, + owner: this.#config.owner, + repo: this.#config.repo, + tree: entries.map((entry) => ({ + content: entry.content, + mode: entry.mode ?? DEFAULT_FILE_MODE, + path: entry.path, + type: entry.type ?? DEFAULT_TREE_TYPE, + })), + }) + return GitShaResponseSchema.parse(response.data).sha + } + + async #createCommit(message: string, treeSha: string, baseCommitSha: string): Promise { + const response = await this.#octokit.git.createCommit({ + message, + owner: this.#config.owner, + parents: [baseCommitSha], + repo: this.#config.repo, + tree: treeSha, + }) + return GitShaResponseSchema.parse(response.data).sha + } + + async #updateBranch(commitSha: string): Promise { + await this.#octokit.git.updateRef({ + force: false, + owner: this.#config.owner, + ref: `heads/${this.#config.branch}`, + repo: this.#config.repo, + sha: commitSha, + }) + } +} + +function classifyGitDatabaseError(config: GitHubConfig, error: unknown): Error { + const rateLimit = rateLimitContext(error) + if (rateLimit.isRateLimited) { + return new GitHubGitDatabaseRateLimitError( + rateLimitMessage(config, rateLimit.retryAfterSeconds), + rateLimit.retryAfterSeconds, + ) + } + if (isGitHubConflict(error)) { + return new GitHubGitDatabaseConflictError( + `GitHub branch ${config.owner}/${config.repo}@${config.branch} moved before GitDB could update it.`, + ) + } + if (isGitHubTransient(error)) { + const status = githubStatus(error) + return new GitHubGitDatabaseTransientError( + `GitHub Git Database API returned transient status ${status} for ${config.owner}/${config.repo}@${config.branch}.`, + ) + } + return error instanceof Error ? error : new GitDbStorageError("unknown GitHub Git Database error") +} + +type RateLimitContext = { + readonly isRateLimited: boolean + readonly retryAfterSeconds: number | null +} + +function rateLimitContext(error: unknown): RateLimitContext { + const parsed = GitHubErrorContextSchema.safeParse(error) + if (!parsed.success) { + return { isRateLimited: false, retryAfterSeconds: null } + } + const retryAfterSeconds = parseRetryAfter(parsed.data.response?.headers?.["retry-after"] ?? null) + const remaining = normalizeHeader(parsed.data.response?.headers?.["x-ratelimit-remaining"]) + const status = parsed.data.status + return { + isRateLimited: status === 429 || retryAfterSeconds !== null || remaining === "0", + retryAfterSeconds, + } +} + +function rateLimitMessage(config: GitHubConfig, retryAfterSeconds: number | null): string { + const retry = + retryAfterSeconds === null + ? "Retry timing was not provided." + : `Retry after ${retryAfterSeconds}s.` + return `GitHub rate limited GitDB writes for ${config.owner}/${config.repo}@${config.branch}. ${retry}` +} + +function parseRetryAfter(value: unknown): number | null { + const normalized = normalizeHeader(value) + if (normalized === null) { + return null + } + const retryAfterSeconds = Number.parseInt(normalized, 10) + return Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0 ? retryAfterSeconds : null +} + +function normalizeHeader(value: unknown): string | null { + if (typeof value === "string") { + return value + } + if (typeof value === "number") { + return value.toString() + } + return null +} diff --git a/src/github/github-encrypted-store.ts b/src/github/github-encrypted-store.ts index 6579aca..676340c 100644 --- a/src/github/github-encrypted-store.ts +++ b/src/github/github-encrypted-store.ts @@ -2,23 +2,36 @@ import { Octokit } from "@octokit/rest" import { z } from "zod" import type { Cipher } from "../crypto/aes-gcm.js" import { GitDbStorageError } from "../errors.js" -import type { GitDbStore } from "../storage/store.js" -import { type GitDbManifest, type PersistedMutation, type SegmentId, segmentId } from "../types.js" import { - gitHubWriteError, - isGitHubConflict, - isGitHubNotFound, - isGitHubTransient, -} from "./errors.js" -import { ensureGitHubRepository } from "./repository.js" + type GitDbCommitBatch, + type GitDbCommitResult, + type GitDbStore, + storeCapabilities, +} from "../storage/store.js" +import { + type GitDbManifest, + type GitDbManifestMetadata, + type PersistedMutation, + type SegmentId, + segmentId, +} from "../types.js" +import { isGitHubNotFound } from "./errors.js" +import type { GitHubTreeEntry } from "./git-database-client.js" +import { GitHubTreeCommitWriter } from "./tree-commit-writer.js" import { type GitHubConfig, GitHubFileSchema } from "./types.js" const ManifestSchema = z.object({ - version: z.literal(1), + version: z.union([z.literal(1), z.literal(2)]), sequence: z.number().int().nonnegative(), createdAt: z.string(), updatedAt: z.string(), logSegments: z.array(z.string()), + metadata: z + .object({ + migratedFrom: z.literal(1).optional(), + storageEngine: z.literal("gitdb"), + }) + .optional(), }) const MutationSchema = z.object({ @@ -27,18 +40,14 @@ const MutationSchema = z.object({ at: z.string(), }) -type WriteFileInput = { - readonly path: string - readonly message: string - readonly plaintext: unknown -} - const GITHUB_API_VERSION = "2022-11-28" export class GitHubEncryptedStore implements GitDbStore { + readonly capabilities = storeCapabilities({ encrypted: true, remote: true, treeCommits: true }) readonly #octokit: Octokit readonly #config: GitHubConfig readonly #cipher: Cipher + readonly #writer: GitHubTreeCommitWriter constructor(config: GitHubConfig, cipher: Cipher) { this.#octokit = new Octokit({ @@ -51,38 +60,67 @@ export class GitHubEncryptedStore implements GitDbStore { }) this.#config = config this.#cipher = cipher + this.#writer = new GitHubTreeCommitWriter(config, this.#octokit) } async readManifest(): Promise { - const payload = await this.#readNullable(`${this.#config.prefix}/manifest.enc`) + const path = `${this.#config.prefix}/manifest.enc` + const payload = await this.#readNullable(path) if (payload === null) { return null } - const parsed = ManifestSchema.parse(JSON.parse(this.#cipher.open(payload).toString("utf8"))) - return { - ...parsed, - logSegments: parsed.logSegments.map(segmentId), + try { + const parsed = ManifestSchema.parse(JSON.parse(this.#cipher.open(payload).toString("utf8"))) + const manifest = { + createdAt: parsed.createdAt, + logSegments: parsed.logSegments.map(segmentId), + sequence: parsed.sequence, + updatedAt: parsed.updatedAt, + version: parsed.version, + } + const metadata = normalizeManifestMetadata(parsed.metadata) + return metadata === undefined ? manifest : { ...manifest, metadata } + } catch (error) { + throw manifestReadError(path, error) } } async writeManifest(manifest: GitDbManifest): Promise { - await this.#writeFile({ + await this.#commitTree({ + entries: [this.#encryptedEntry(`${this.#config.prefix}/manifest.enc`, manifest)], message: "gitdb sync manifest", - path: `${this.#config.prefix}/manifest.enc`, - plaintext: manifest, }) } async appendMutation(mutation: PersistedMutation): Promise { const id = segmentId(mutation.sequence.toString().padStart(20, "0")) - await this.#writeFile({ + await this.#commitTree({ + entries: [this.#encryptedEntry(`${this.#config.prefix}/log/${id}.enc`, mutation)], message: "gitdb sync segment", - path: `${this.#config.prefix}/log/${id}.enc`, - plaintext: mutation, }) return id } + async commitBatch(batch: GitDbCommitBatch): Promise { + const segments = batch.mutations.map((mutation) => + segmentId(mutation.sequence.toString().padStart(20, "0")), + ) + await this.#commitTree({ + beforeAttempt: () => this.#assertManifestCompareAndSwap(batch.compareAndSwap), + entries: [ + ...batch.mutations.map((mutation) => + this.#encryptedEntry( + `${this.#config.prefix}/log/${segmentId(mutation.sequence.toString().padStart(20, "0"))}.enc`, + mutation, + ), + ), + this.#encryptedEntry(`${this.#config.prefix}/manifest.enc`, batch.manifest), + ], + message: `gitdb sync encrypted batch ${batch.manifest.sequence}`, + }) + return { manifest: batch.manifest, segments } + } + async readMutations(segments: readonly SegmentId[]): Promise { const mutations: PersistedMutation[] = [] for (const segment of segments) { @@ -116,80 +154,50 @@ export class GitHubEncryptedStore implements GitDbStore { } } - async #writeFile(input: WriteFileInput): Promise { - let repositoryBootstrapped = false - for (let attempt = 0; attempt < 5; attempt += 1) { - const existing = await this.#getExistingSha(input.path) - const request = this.#writeRequest(input, existing) - try { - await this.#octokit.repos.createOrUpdateFileContents(request) - return - } catch (error) { - if (isGitHubNotFound(error) && !repositoryBootstrapped) { - await ensureGitHubRepository(this.#octokit, this.#config) - repositoryBootstrapped = true - continue - } - if (isGitHubConflict(error)) { - continue - } - if (isGitHubTransient(error)) { - await sleep(500 * (attempt + 1)) - continue - } - throw this.#writeError(input.path, error) - } + async #commitTree(input: { + readonly beforeAttempt?: () => Promise + readonly entries: readonly GitHubTreeEntry[] + readonly message: string + }): Promise { + if (input.entries.length === 0) { + return } - throw new GitDbStorageError( - `GitHub write conflict did not settle for ${this.#config.owner}/${this.#config.repo}@${this.#config.branch}:${input.path}`, - ) + await this.#writer.commit(input) } - #writeRequest( - input: WriteFileInput, - existing: string | null, - ): Parameters[0] { - const sealed = this.#cipher.seal(Buffer.from(JSON.stringify(input.plaintext), "utf8")) - return { - branch: this.#config.branch, - content: Buffer.from(sealed, "utf8").toString("base64"), - message: input.message, - owner: this.#config.owner, - path: input.path, - repo: this.#config.repo, - ...(existing === null ? {} : { sha: existing }), + async #assertManifestCompareAndSwap( + compareAndSwap: GitDbCommitBatch["compareAndSwap"], + ): Promise { + if (compareAndSwap === undefined) { + return + } + const current = await this.readManifest() + const currentSequence = current?.sequence ?? null + if (currentSequence !== compareAndSwap.expectedSequence) { + throw new GitDbStorageError( + `manifest compare-and-swap failed: expected sequence ${compareAndSwap.expectedSequence}, got ${currentSequence}`, + ) } } - #writeError(path: string, error: unknown): unknown { - const writeError = gitHubWriteError(this.#config, path, error) - return writeError ?? error + #encryptedEntry(path: string, value: unknown): GitHubTreeEntry { + const sealed = this.#cipher.seal(Buffer.from(JSON.stringify(value), "utf8")) + return { content: sealed, path } } +} - async #getExistingSha(path: string): Promise { - try { - const response = await this.#octokit.repos.getContent({ - owner: this.#config.owner, - repo: this.#config.repo, - path, - ref: this.#config.branch, - }) - const parsed = GitHubFileSchema.safeParse(response.data) - if (!parsed.success) { - throw new GitDbStorageError(`GitHub path is not a file: ${path}`) - } - return parsed.data.sha - } catch (error) { - if (isGitHubNotFound(error)) { - return null - } - throw error - } - } +function manifestReadError(path: string, error: unknown): GitDbStorageError { + const detail = error instanceof Error ? error.message : String(error) + return new GitDbStorageError(`failed to read GitHub manifest at ${path}: ${detail}`) } -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms) - }) +function normalizeManifestMetadata( + metadata: { readonly migratedFrom?: 1 | undefined; readonly storageEngine: "gitdb" } | undefined, +): GitDbManifestMetadata | undefined { + if (metadata === undefined) { + return undefined + } + return metadata.migratedFrom === undefined + ? { storageEngine: metadata.storageEngine } + : { migratedFrom: metadata.migratedFrom, storageEngine: metadata.storageEngine } } diff --git a/src/github/github-plaintext-store.ts b/src/github/github-plaintext-store.ts index 29da3f4..1859641 100644 --- a/src/github/github-plaintext-store.ts +++ b/src/github/github-plaintext-store.ts @@ -3,40 +3,49 @@ import { GitDbStorageError } from "../errors.js" import { parsePlaintextManifest, parsePlaintextMutation, - parseVisibleTableRows, - parseVisibleTableSchema, segmentIdForSequence, stringifyPlaintext, } from "../storage/plaintext-codec.js" -import type { GitDbStore } from "../storage/store.js" +import { + type GitDbCommitBatch, + type GitDbCommitResult, + type GitDbStore, + storeCapabilities, +} from "../storage/store.js" +import { DEFAULT_VISIBLE_SNAPSHOT_PAGE_SIZE_BYTES } from "../storage/visible-pages.js" import type { GitDbManifest, PersistedMutation, SegmentId, VisibleDatabaseSnapshot, } from "../types.js" +import { isGitHubNotFound } from "./errors.js" +import type { GitHubTreeEntry } from "./git-database-client.js" import { - gitHubWriteError, - isGitHubConflict, - isGitHubNotFound, - isGitHubTransient, -} from "./errors.js" -import { ensureGitHubRepository } from "./repository.js" + githubVisibleSnapshotEntries, + readGitHubVisibleSnapshot, +} from "./github-visible-snapshot.js" +import { GitHubTreeCommitWriter } from "./tree-commit-writer.js" import { type GitHubConfig, GitHubDirectoryEntrySchema, GitHubFileSchema } from "./types.js" -type WriteFileInput = { - readonly path: string - readonly message: string - readonly plaintext: unknown -} - const GITHUB_API_VERSION = "2022-11-28" +export type GitHubPlaintextStoreOptions = { + readonly visibleSnapshotPageSizeBytes?: number +} + export class GitHubPlaintextStore implements GitDbStore { + readonly capabilities = storeCapabilities({ + remote: true, + treeCommits: true, + visibleSnapshots: true, + }) readonly #octokit: Octokit readonly #config: GitHubConfig + readonly #writer: GitHubTreeCommitWriter + readonly #visibleSnapshotPageSizeBytes: number - constructor(config: GitHubConfig) { + constructor(config: GitHubConfig, options: GitHubPlaintextStoreOptions = {}) { this.#octokit = new Octokit({ auth: config.token, request: { @@ -46,6 +55,9 @@ export class GitHubPlaintextStore implements GitDbStore { }, }) this.#config = config + this.#writer = new GitHubTreeCommitWriter(config, this.#octokit) + this.#visibleSnapshotPageSizeBytes = + options.visibleSnapshotPageSizeBytes ?? DEFAULT_VISIBLE_SNAPSHOT_PAGE_SIZE_BYTES } async readManifest(): Promise { @@ -54,23 +66,39 @@ export class GitHubPlaintextStore implements GitDbStore { } async writeManifest(manifest: GitDbManifest): Promise { - await this.#writeFile({ + await this.#commitTree({ + entries: [plaintextEntry(`${this.#config.prefix}/manifest.json`, manifest)], message: "gitdb sync plaintext manifest", - path: `${this.#config.prefix}/manifest.json`, - plaintext: manifest, }) } async appendMutation(mutation: PersistedMutation): Promise { const id = segmentIdForSequence(mutation.sequence) - await this.#writeFile({ + await this.#commitTree({ + entries: [plaintextEntry(`${this.#config.prefix}/log/${id}.json`, mutation)], message: "gitdb sync plaintext segment", - path: `${this.#config.prefix}/log/${id}.json`, - plaintext: mutation, }) return id } + async commitBatch(batch: GitDbCommitBatch): Promise { + const segments = batch.mutations.map((mutation) => segmentIdForSequence(mutation.sequence)) + await this.#commitTree({ + beforeAttempt: () => this.#assertManifestCompareAndSwap(batch.compareAndSwap), + entries: [ + ...batch.mutations.map((mutation) => + plaintextEntry( + `${this.#config.prefix}/log/${segmentIdForSequence(mutation.sequence)}.json`, + mutation, + ), + ), + plaintextEntry(`${this.#config.prefix}/manifest.json`, batch.manifest), + ], + message: `gitdb sync plaintext batch ${batch.manifest.sequence}`, + }) + return { manifest: batch.manifest, segments } + } + async readMutations(segments: readonly SegmentId[]): Promise { const mutations: PersistedMutation[] = [] for (const segment of segments) { @@ -84,40 +112,22 @@ export class GitHubPlaintextStore implements GitDbStore { } async readVisibleSnapshot(): Promise { - const tableNames = await this.#readTableNames() - const tables = [] - for (const tableName of tableNames) { - const schemaPayload = await this.#readNullable( - `${this.#config.prefix}/${tableName}/schema.json`, - ) - const dataPayload = await this.#readNullable(`${this.#config.prefix}/${tableName}/data.json`) - if (schemaPayload !== null && dataPayload !== null) { - const schema = parseVisibleTableSchema(schemaPayload) - tables.push({ - ...schema, - rows: parseVisibleTableRows(dataPayload), - }) - } - } - return tables.length === 0 ? null : { tables } + return await readGitHubVisibleSnapshot({ + prefix: this.#config.prefix, + readNullable: (path) => this.#readNullable(path), + readTableNames: () => this.#readTableNames(), + }) } async writeVisibleSnapshot(snapshot: VisibleDatabaseSnapshot): Promise { - for (const table of snapshot.tables) { - await this.#writeFile({ - message: `gitdb sync ${table.name} schema`, - path: `${this.#config.prefix}/${table.name}/schema.json`, - plaintext: { - columns: table.columns, - name: table.name, - }, - }) - await this.#writeFile({ - message: `gitdb sync ${table.name} data`, - path: `${this.#config.prefix}/${table.name}/data.json`, - plaintext: table.rows, - }) - } + await this.#commitTree({ + entries: githubVisibleSnapshotEntries( + this.#config.prefix, + snapshot, + this.#visibleSnapshotPageSizeBytes, + ), + message: `gitdb sync plaintext snapshot ${snapshot.sequence ?? "latest"}`, + }) } async #readTableNames(): Promise { @@ -166,79 +176,33 @@ export class GitHubPlaintextStore implements GitDbStore { } } - async #writeFile(input: WriteFileInput): Promise { - let repositoryBootstrapped = false - for (let attempt = 0; attempt < 5; attempt += 1) { - const existing = await this.#getExistingSha(input.path) - const request = this.#writeRequest(input, existing) - try { - await this.#octokit.repos.createOrUpdateFileContents(request) - return - } catch (error) { - if (isGitHubNotFound(error) && !repositoryBootstrapped) { - await ensureGitHubRepository(this.#octokit, this.#config) - repositoryBootstrapped = true - continue - } - if (isGitHubConflict(error)) { - continue - } - if (isGitHubTransient(error)) { - await sleep(500 * (attempt + 1)) - continue - } - throw this.#writeError(input.path, error) - } + async #commitTree(input: { + readonly beforeAttempt?: () => Promise + readonly entries: readonly GitHubTreeEntry[] + readonly message: string + }): Promise { + if (input.entries.length === 0) { + return } - throw new GitDbStorageError( - `GitHub write conflict did not settle for ${this.#config.owner}/${this.#config.repo}@${this.#config.branch}:${input.path}`, - ) + await this.#writer.commit(input) } - #writeRequest( - input: WriteFileInput, - existing: string | null, - ): Parameters[0] { - return { - branch: this.#config.branch, - content: Buffer.from(stringifyPlaintext(input.plaintext), "utf8").toString("base64"), - message: input.message, - owner: this.#config.owner, - path: input.path, - repo: this.#config.repo, - ...(existing === null ? {} : { sha: existing }), + async #assertManifestCompareAndSwap( + compareAndSwap: GitDbCommitBatch["compareAndSwap"], + ): Promise { + if (compareAndSwap === undefined) { + return } - } - - #writeError(path: string, error: unknown): unknown { - const writeError = gitHubWriteError(this.#config, path, error) - return writeError ?? error - } - - async #getExistingSha(path: string): Promise { - try { - const response = await this.#octokit.repos.getContent({ - owner: this.#config.owner, - repo: this.#config.repo, - path, - ref: this.#config.branch, - }) - const parsed = GitHubFileSchema.safeParse(response.data) - if (!parsed.success) { - throw new GitDbStorageError(`GitHub path is not a file: ${path}`) - } - return parsed.data.sha - } catch (error) { - if (isGitHubNotFound(error)) { - return null - } - throw error + const current = await this.readManifest() + const currentSequence = current?.sequence ?? null + if (currentSequence !== compareAndSwap.expectedSequence) { + throw new GitDbStorageError( + `manifest compare-and-swap failed: expected sequence ${compareAndSwap.expectedSequence}, got ${currentSequence}`, + ) } } } -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms) - }) +function plaintextEntry(path: string, value: unknown): GitHubTreeEntry { + return { content: stringifyPlaintext(value), path } } diff --git a/src/github/github-visible-snapshot.ts b/src/github/github-visible-snapshot.ts new file mode 100644 index 0000000..5b9edf2 --- /dev/null +++ b/src/github/github-visible-snapshot.ts @@ -0,0 +1,109 @@ +import { + parseVisibleSnapshotCheckpoint, + parseVisibleTablePageIndex, + parseVisibleTableRows, + parseVisibleTableSchema, + stringifyPlaintext, +} from "../storage/plaintext-codec.js" +import { visibleSnapshotFiles } from "../storage/visible-pages.js" +import type { SqlRow, VisibleDatabaseSnapshot, VisibleTableSnapshot } from "../types.js" +import type { GitHubTreeEntry } from "./git-database-client.js" + +type GitHubVisibleSnapshotInput = { + readonly prefix: string + readonly readNullable: (path: string) => Promise + readonly readTableNames: () => Promise +} + +type VisibleRowsRead = + | { readonly kind: "invalid" } + | { readonly kind: "missing" } + | { readonly kind: "rows"; readonly rows: VisibleTableSnapshot["rows"] } + +export function githubVisibleSnapshotEntries( + prefix: string, + snapshot: VisibleDatabaseSnapshot, + pageSizeBytes: number, +): readonly GitHubTreeEntry[] { + return visibleSnapshotFiles(snapshot, pageSizeBytes).map((file) => ({ + content: stringifyPlaintext(file.value), + path: `${prefix}/${file.path}`, + })) +} + +export async function readGitHubVisibleSnapshot( + input: GitHubVisibleSnapshotInput, +): Promise { + const checkpoint = await readSnapshotCheckpoint(input) + const tables: VisibleTableSnapshot[] = [] + for (const tableName of await input.readTableNames()) { + const schemaPayload = await input.readNullable(`${input.prefix}/${tableName}/schema.json`) + if (schemaPayload === null) { + continue + } + const rows = await readVisibleRows(input, tableName) + switch (rows.kind) { + case "missing": + continue + case "invalid": + return null + case "rows": + tables.push({ ...parseVisibleTableSchema(schemaPayload), rows: rows.rows }) + break + default: + return assertNever(rows) + } + } + if (tables.length === 0) { + return null + } + return checkpoint === undefined ? { tables } : { sequence: checkpoint, tables } +} + +async function readVisibleRows( + input: GitHubVisibleSnapshotInput, + tableName: string, +): Promise { + const pageIndexPayload = await input.readNullable(`${input.prefix}/${tableName}/pages.json`) + if (pageIndexPayload !== null) { + return await readPagedRows(input, tableName, pageIndexPayload) + } + const dataPayload = await input.readNullable(`${input.prefix}/${tableName}/data.json`) + return dataPayload === null + ? { kind: "missing" } + : { kind: "rows", rows: parseVisibleTableRows(dataPayload) } +} + +async function readPagedRows( + input: GitHubVisibleSnapshotInput, + tableName: string, + pageIndexPayload: string, +): Promise { + const pageIndex = parseVisibleTablePageIndex(pageIndexPayload) + const rows: SqlRow[] = [] + for (const page of pageIndex.pages) { + const payload = await input.readNullable(`${input.prefix}/${tableName}/pages/${page}`) + if (payload === null) { + return { kind: "invalid" } + } + rows.push(...parseVisibleTableRows(payload)) + } + if (rows.length < pageIndex.rowCount) { + return { kind: "invalid" } + } + return { kind: "rows", rows: rows.slice(0, pageIndex.rowCount) } +} + +async function readSnapshotCheckpoint( + input: GitHubVisibleSnapshotInput, +): Promise { + const payload = await input.readNullable(`${input.prefix}/snapshot.json`) + if (payload === null) { + return undefined + } + return parseVisibleSnapshotCheckpoint(payload) +} + +function assertNever(value: never): never { + throw new TypeError(`unexpected visible rows result ${JSON.stringify(value)}`) +} diff --git a/src/github/tree-commit-writer.ts b/src/github/tree-commit-writer.ts new file mode 100644 index 0000000..0b91cbd --- /dev/null +++ b/src/github/tree-commit-writer.ts @@ -0,0 +1,77 @@ +import type { Octokit } from "@octokit/rest" +import { GitDbStorageError } from "../errors.js" +import { isGitHubNotFound } from "./errors.js" +import { + GitHubGitDatabaseClient, + GitHubGitDatabaseConflictError, + GitHubGitDatabaseRateLimitError, + GitHubGitDatabaseTransientError, + type GitHubTreeEntry, +} from "./git-database-client.js" +import { ensureGitHubRepository } from "./repository.js" +import type { GitHubConfig } from "./types.js" + +const MAX_TREE_COMMIT_ATTEMPTS = 5 + +export type GitHubTreeCommitInput = { + readonly beforeAttempt?: () => Promise + readonly entries: readonly GitHubTreeEntry[] + readonly message: string +} + +export class GitHubTreeCommitWriter { + readonly #config: GitHubConfig + readonly #gitClient: GitHubGitDatabaseClient + readonly #octokit: Octokit + + constructor(config: GitHubConfig, octokit: Octokit) { + this.#config = config + this.#gitClient = new GitHubGitDatabaseClient(config) + this.#octokit = octokit + } + + async commit(input: GitHubTreeCommitInput): Promise { + let repositoryBootstrapped = false + for (let attempt = 0; attempt < MAX_TREE_COMMIT_ATTEMPTS; attempt += 1) { + try { + await input.beforeAttempt?.() + await this.#gitClient.commitTree(input) + return + } catch (error) { + if (isBootstrapNeeded(error, repositoryBootstrapped)) { + await ensureGitHubRepository(this.#octokit, this.#config) + repositoryBootstrapped = true + continue + } + if (error instanceof GitHubGitDatabaseConflictError) { + continue + } + if (error instanceof GitHubGitDatabaseTransientError) { + await sleep(retryDelayMs(error, attempt)) + continue + } + throw error + } + } + throw new GitDbStorageError( + `GitHub tree commit did not settle for ${this.#config.owner}/${this.#config.repo}@${this.#config.branch}`, + ) + } +} + +function isBootstrapNeeded(error: unknown, repositoryBootstrapped: boolean): boolean { + return !repositoryBootstrapped && isGitHubNotFound(error) +} + +function retryDelayMs(error: GitHubGitDatabaseTransientError, attempt: number): number { + if (error instanceof GitHubGitDatabaseRateLimitError && error.retryAfterSeconds !== null) { + return Math.min(error.retryAfterSeconds * 1000, 1000) + } + return 500 * (attempt + 1) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} diff --git a/src/http/app.module.ts b/src/http/app.module.ts deleted file mode 100644 index 9c44695..0000000 --- a/src/http/app.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Module } from "@nestjs/common" -import { GitDbController } from "./gitdb.controller.js" - -@Module({ - controllers: [GitDbController], -}) -export class AppModule {} diff --git a/src/http/gitdb.controller.ts b/src/http/gitdb.controller.ts deleted file mode 100644 index f190d90..0000000 --- a/src/http/gitdb.controller.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Controller, Get } from "@nestjs/common" -import type { StoreMode } from "../cli/store-factory.js" -import { gitDbRuntime } from "./gitdb.runtime.js" - -type HealthResponse = { - readonly facade: { - readonly host: string - readonly port: number - } | null - readonly mode: StoreMode - readonly status: "ready" | "starting" -} - -@Controller() -export class GitDbController { - @Get("/") - root(): HealthResponse { - return gitDbRuntime.health() - } - - @Get("/health") - health(): HealthResponse { - return gitDbRuntime.health() - } -} diff --git a/src/http/gitdb.runtime.ts b/src/http/gitdb.runtime.ts deleted file mode 100644 index 698fe36..0000000 --- a/src/http/gitdb.runtime.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createStoreFromEnv, type StoreMode } from "../cli/store-factory.js" -import { createGitDbServer, type GitDbServer } from "../protocol/postgres-server.js" -import { GitDbEngine } from "../sql/engine.js" - -export class GitDbRuntime { - #server: GitDbServer | null = null - #mode: StoreMode = "local" - - async start(): Promise { - const { env, mode, store } = createStoreFromEnv(process.env) - const engine = await GitDbEngine.open({ store }) - this.#server = await createGitDbServer({ engine, host: env.GITDB_HOST, port: env.GITDB_PORT }) - this.#mode = mode - } - - async stop(): Promise { - await this.#server?.close() - this.#server = null - } - - health(): { - readonly facade: { readonly host: string; readonly port: number } | null - readonly mode: StoreMode - readonly status: "ready" | "starting" - } { - return { - facade: this.#server === null ? null : { host: this.#server.host, port: this.#server.port }, - mode: this.#mode, - status: this.#server === null ? "starting" : "ready", - } - } -} - -export const gitDbRuntime = new GitDbRuntime() diff --git a/src/http/main.ts b/src/http/main.ts deleted file mode 100644 index a2493ec..0000000 --- a/src/http/main.ts +++ /dev/null @@ -1,27 +0,0 @@ -import "reflect-metadata" -import { NestFactory } from "@nestjs/core" -import pino from "pino" -import { AppModule } from "./app.module.js" -import { gitDbRuntime } from "./gitdb.runtime.js" - -const logger = pino({ level: process.env["LOG_LEVEL"] ?? "info" }) - -async function bootstrap(): Promise { - await gitDbRuntime.start() - const app = await NestFactory.create(AppModule, { logger: false }) - const httpPort = Number.parseInt(process.env["PORT"] ?? "3000", 10) - await app.listen(httpPort, "0.0.0.0") - logger.info({ httpPort }, "gitdb control plane listening") -} - -bootstrap().catch((error: unknown) => { - if (error instanceof Error) { - logger.error({ error: error.message, stack: error.stack }, "gitdb http service failed") - process.stderr.write(`${error.stack ?? error.message}\n`) - process.exitCode = 1 - return - } - logger.error({ error: String(error) }, "gitdb http service failed") - process.stderr.write(`${String(error)}\n`) - process.exitCode = 1 -}) diff --git a/src/index.ts b/src/index.ts index 2be50b5..3892180 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,47 @@ export { generateKey } from "./cli/keygen.js" export { createAesGcmCipher } from "./crypto/aes-gcm.js" export { GitHubEncryptedStore } from "./github/github-encrypted-store.js" -export { createGitDbServer } from "./protocol/postgres-server.js" -export { GitDbEngine } from "./sql/engine.js" +export { + createGitDbDataSource, + defineEntity, + type EntityDefinition, + type FindOptions, + type GitDbColumnType, + GitDbDataSource, + type GitDbDataSourceOptions, + type GitDbEntityDefinition, + type GitDbEntityIndexDefinition, + type GitDbInsertResult, + GitDbRepository, + type GitDbSaveManyResult, +} from "./orm/index.js" +export { + GitDbEngine, + type GitDbEngineOptions, + type GitDbTransaction, + type SnapshotPolicy, +} from "./sql/engine.js" export { LocalEncryptedStore } from "./storage/local-encrypted-store.js" -export type { GitDbStore } from "./storage/store.js" -export type { GitDbManifest, PersistedMutation, SqlResult, SqlRow } from "./types.js" +export { LocalPlaintextStore } from "./storage/local-plaintext-store.js" +export { + asGitDbStoreV2, + commitBatchThroughStore, + type GitDbCheckpoint, + type GitDbCommitBatch, + type GitDbCommitResult, + type GitDbCompactionInput, + type GitDbCompactionResult, + type GitDbLock, + type GitDbLockInput, + type GitDbStore, + type GitDbStoreCapabilities, + type GitDbStoreV2, + type ManifestCompareAndSwap, +} from "./storage/store.js" +export type { + GitDbDurabilityMode, + GitDbManifest, + PersistedMutation, + SqlResult, + SqlRow, +} from "./types.js" diff --git a/src/orm/index.ts b/src/orm/index.ts new file mode 100644 index 0000000..658ca6e --- /dev/null +++ b/src/orm/index.ts @@ -0,0 +1,270 @@ +import { z } from "zod" +import { GitDbOrmError } from "../errors.js" +import { GitDbEngine, type GitDbTransaction, type SnapshotPolicy } from "../sql/engine.js" +import { sqlLiteral } from "../sql/rows.js" +import type { GitDbStore } from "../storage/store.js" +import type { JsonPrimitive, SqlRow } from "../types.js" + +const GitDbColumnTypes = ["STRING", "INT", "FLOAT", "BOOL"] as const + +export type GitDbColumnType = (typeof GitDbColumnTypes)[number] + +export type GitDbEntityIndexDefinition = { + readonly columns: readonly ColumnName[] + readonly name: string +} + +export type GitDbEntityDefinition = { + readonly columns: Readonly> + readonly indexes?: readonly GitDbEntityIndexDefinition[] + readonly primaryKey: string + readonly tableName: string +} + +export type EntityDefinition = Omit< + GitDbEntityDefinition, + "columns" | "indexes" | "primaryKey" +> & { + readonly columns: { readonly [Key in Extract]: GitDbColumnType } + readonly indexes?: readonly GitDbEntityIndexDefinition>[] + readonly primaryKey: Extract +} + +export type FindOptions = { + readonly where?: Partial, JsonPrimitive>> +} + +export type GitDbDataSourceOptions = { + readonly entities: readonly GitDbEntityDefinition[] + readonly snapshotPolicy?: SnapshotPolicy + readonly store: GitDbStore + readonly synchronize?: boolean +} + +export type GitDbInsertResult = { + readonly inserted: number +} + +export type GitDbSaveManyResult = { + readonly saved: number +} + +const SqlIdentifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/) + +const EntityDefinitionSchema = z + .object({ + columns: z.record(z.string().min(1), z.enum(GitDbColumnTypes)), + indexes: z + .array( + z.object({ + columns: z.array(z.string().min(1)).min(1), + name: SqlIdentifierSchema, + }), + ) + .optional(), + primaryKey: z.string().min(1), + tableName: SqlIdentifierSchema, + }) + .superRefine((definition, context) => { + if (!(definition.primaryKey in definition.columns)) { + context.addIssue({ + code: "custom", + message: "primaryKey must exist in columns", + path: ["primaryKey"], + }) + } + definition.indexes?.forEach((index, indexOffset) => { + index.columns.forEach((column, columnOffset) => { + if (!(column in definition.columns)) { + context.addIssue({ + code: "custom", + message: `indexed column ${column} does not exist`, + path: ["indexes", indexOffset, "columns", columnOffset], + }) + } + }) + }) + }) + +export function defineEntity( + definition: EntityDefinition, +): EntityDefinition { + EntityDefinitionSchema.parse(definition) + return definition +} + +export async function createGitDbDataSource( + options: GitDbDataSourceOptions, +): Promise { + const engine = + options.snapshotPolicy === undefined + ? await GitDbEngine.open({ store: options.store }) + : await GitDbEngine.open({ + snapshotPolicy: options.snapshotPolicy, + store: options.store, + }) + const dataSource = new GitDbDataSource(engine) + if (options.synchronize === true) { + for (const entity of options.entities) { + await dataSource.synchronize(entity) + } + } + return dataSource +} + +export class GitDbDataSource { + readonly #engine: GitDbEngine + + constructor(engine: GitDbEngine) { + this.#engine = engine + } + + async query(sql: string): Promise { + return (await this.#engine.execute(sql)).rows + } + + getRepository(entity: EntityDefinition): GitDbRepository { + return new GitDbRepository(this.#engine, entity) + } + + async synchronize(entity: GitDbEntityDefinition): Promise { + await this.#engine.execute(createTableSql(entity)) + } + + async transaction(work: (transaction: GitDbTransaction) => Promise): Promise { + return await this.#engine.transaction(work) + } +} + +export class GitDbRepository { + readonly #engine: GitDbEngine + readonly #entity: EntityDefinition + + constructor(engine: GitDbEngine, entity: EntityDefinition) { + this.#engine = engine + this.#entity = entity + } + + async save(row: Row): Promise { + await this.saveMany([row]) + } + + async saveMany(rows: readonly Row[]): Promise { + if (rows.length === 0) { + return { saved: 0 } + } + await this.#engine.transaction(async (transaction) => { + for (const row of rows) { + await transaction.execute(deleteSql(this.#entity, primaryKeyWhere(this.#entity, row))) + await transaction.execute(insertSql(this.#entity, row)) + } + }) + return { saved: rows.length } + } + + async insert(row: Row): Promise { + return await this.insertMany([row]) + } + + async insertMany(rows: readonly Row[]): Promise { + if (rows.length === 0) { + return { inserted: 0 } + } + await this.#engine.transaction(async (transaction) => { + for (const row of rows) { + await transaction.execute(insertSql(this.#entity, row)) + } + }) + return { inserted: rows.length } + } + + async find(options: FindOptions = {}): Promise { + const result = await this.#engine.execute(selectSql(this.#entity, options.where ?? {})) + return result.rows.map((row) => row as Row) + } + + async findOne(options: FindOptions): Promise { + const rows = await this.find(options) + return rows[0] ?? null + } + + async delete(where: FindOptions["where"]): Promise { + if (where === undefined || Object.keys(where).length === 0) { + throw new GitDbOrmError("delete requires a non-empty where clause") + } + const result = await this.#engine.execute(deleteSql(this.#entity, where)) + return result.rowCount + } +} + +function createTableSql(entity: GitDbEntityDefinition): string { + const columns = Object.entries(entity.columns) + .map(([name, type]) => `${identifier(name)} ${type}`) + .join(", ") + return `CREATE TABLE IF NOT EXISTS ${identifier(entity.tableName)} (${columns})` +} + +function insertSql(entity: EntityDefinition, row: Row): string { + const columns = Object.keys(entity.columns) + const names = columns.map(identifier).join(", ") + const values = columns.map((column) => sqlLiteral(valueForKey(row, column))).join(", ") + return `INSERT INTO ${identifier(entity.tableName)} (${names}) VALUES (${values})` +} + +function selectSql( + entity: EntityDefinition, + where: FindOptions["where"], +): string { + const clause = whereClause(where) + return `SELECT * FROM ${identifier(entity.tableName)}${clause}` +} + +function deleteSql( + entity: EntityDefinition, + where: FindOptions["where"], +): string { + const clause = whereClause(where) + if (clause.length === 0) { + throw new GitDbOrmError("delete requires a non-empty where clause") + } + return `DELETE FROM ${identifier(entity.tableName)}${clause}` +} + +function whereClause(where: FindOptions["where"]): string { + if (where === undefined || Object.keys(where).length === 0) { + return "" + } + const parts = Object.entries(where).map( + ([column, value]) => `${identifier(column)} = ${sqlLiteral(value)}`, + ) + return ` WHERE ${parts.join(" AND ")}` +} + +function identifier(value: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { + throw new GitDbOrmError(`invalid SQL identifier: ${value}`) + } + return value +} + +function valueForKey(row: Row, key: string): JsonPrimitive { + const value: unknown = Reflect.get(row, key) + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value + } + throw new GitDbOrmError(`entity value for ${key} must be a JSON primitive`) +} + +function primaryKeyWhere( + entity: EntityDefinition, + row: Row, +): Partial, JsonPrimitive>> { + const where: Partial, JsonPrimitive>> = {} + where[entity.primaryKey] = valueForKey(row, entity.primaryKey) + return where +} diff --git a/src/protocol/field.ts b/src/protocol/field.ts deleted file mode 100644 index 95d0c1c..0000000 --- a/src/protocol/field.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { FieldDesc } from "pg-server" -import { primitiveToWire } from "../sql/rows.js" -import type { SqlRow } from "../types.js" - -const TEXT_OID = 25 -const TEXT_SIZE = -1 - -export function describeRows(rows: readonly SqlRow[]): readonly FieldDesc[] { - const first = rows[0] - if (first === undefined) { - return [] - } - return Object.keys(first).map((name, index) => ({ - columnID: index + 1, - dataTypeID: TEXT_OID, - dataTypeModifier: -1, - dataTypeSize: TEXT_SIZE, - mode: "text", - name, - tableID: 0, - })) -} - -export function rowToWire(row: SqlRow, fields: readonly FieldDesc[]): readonly string[] { - return fields.map((field) => primitiveToWire(row[field.name] ?? null) ?? "") -} diff --git a/src/protocol/parameters.ts b/src/protocol/parameters.ts deleted file mode 100644 index d333f90..0000000 --- a/src/protocol/parameters.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { sqlLiteral } from "../sql/rows.js" - -export function bindParameters(sql: string, values: readonly unknown[]): string { - return sql.replaceAll(/\$(\d+)/g, (_match: string, rawIndex: string) => { - const parsed = Number.parseInt(rawIndex, 10) - if (!Number.isSafeInteger(parsed) || parsed < 1) { - return "NULL" - } - return sqlLiteral(values[parsed - 1]) - }) -} diff --git a/src/protocol/postgres-server.ts b/src/protocol/postgres-server.ts deleted file mode 100644 index a820dbf..0000000 --- a/src/protocol/postgres-server.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { Server } from "node:net" -import { - CommandCode, - createAdvancedServer, - type DbRawCommand, - type IAdvanceServerSession, - type IResponseWriter, -} from "pg-server" -import { ProtocolError, SqlExecutionError } from "../errors.js" -import type { GitDbEngine } from "../sql/engine.js" -import { commandTag } from "../sql/normalize.js" -import { describeRows, rowToWire } from "./field.js" -import { bindParameters } from "./parameters.js" - -export type GitDbServer = { - readonly host: string - readonly port: number - readonly close: () => Promise -} - -type ServerOptions = { - readonly engine: GitDbEngine - readonly host: string - readonly port: number -} - -type PreparedStatement = { - readonly sql: string -} - -type Portal = { - readonly sql: string -} - -export async function createGitDbServer(options: ServerOptions): Promise { - const server = createAdvancedServer( - class GitDbSession implements IAdvanceServerSession { - readonly #prepared = new Map() - readonly #portals = new Map() - - onCommand(command: DbRawCommand, response: IResponseWriter): void { - void this.#handle(command, response) - } - - async #handle(command: DbRawCommand, response: IResponseWriter): Promise { - try { - await this.#dispatch(command, response) - } catch (error) { - if (error instanceof SqlExecutionError || error instanceof ProtocolError) { - response.error({ message: error.message, severity: "ERROR" }) - response.readyForQuery() - return - } - if (error instanceof Error) { - response.error({ message: error.message, severity: "ERROR" }) - response.readyForQuery() - return - } - response.error({ message: String(error), severity: "ERROR" }) - response.readyForQuery() - } - } - - async #dispatch(command: DbRawCommand, response: IResponseWriter): Promise { - switch (command.command.type) { - case CommandCode.init: - this.#startup(response) - return - case CommandCode.query: - await this.#runQuery(command.command.query, response) - response.readyForQuery() - return - case CommandCode.parse: - this.#prepared.set(command.command.queryName, { sql: command.command.query }) - response.parseComplete() - return - case CommandCode.bind: { - const prepared = this.#prepared.get(command.command.statement) - const sql = prepared?.sql ?? command.command.statement - this.#portals.set(command.command.portal, { - sql: bindParameters(sql, command.command.values), - }) - response.bindComplete() - return - } - case CommandCode.describe: - response.noData() - return - case CommandCode.execute: { - const portal = this.#portals.get(command.command.portal) - if (portal === undefined) { - throw new ProtocolError(`unknown portal ${command.command.portal}`) - } - await this.#runQuery(portal.sql, response) - return - } - case CommandCode.sync: - case CommandCode.flush: - response.readyForQuery() - return - case CommandCode.close: - response.closeComplete() - return - case CommandCode.end: - response.socket.end() - return - case CommandCode.startup: - case CommandCode.copyDone: - case CommandCode.copyFail: - case CommandCode.copyFromChunk: - throw new ProtocolError(`unsupported command ${command.command.type}`) - default: - return assertNever(command.command) - } - } - - #startup(response: IResponseWriter): void { - response.authenticationOk() - response.parameterStatus("server_version", "16.0-gitdb") - response.parameterStatus("server_encoding", "UTF8") - response.parameterStatus("client_encoding", "UTF8") - response.parameterStatus("DateStyle", "ISO, MDY") - response.parameterStatus("integer_datetimes", "on") - response.backendKeyData(process.pid, 1) - response.readyForQuery() - } - - async #runQuery(sql: string, response: IResponseWriter): Promise { - if (sql.trim().length === 0) { - response.emptyQuery() - return - } - const result = await options.engine.execute(sql) - const fields = describeRows(result.rows) - if (fields.length > 0) { - response.rowDescription([...fields]) - for (const row of result.rows) { - response.dataRow([...rowToWire(row, fields)]) - } - } - response.commandComplete(result.command || commandTag(sql, result.rowCount)) - } - }, - ) - await listen(server, options.port, options.host) - const address = server.address() - const port = typeof address === "object" && address !== null ? address.port : options.port - return { - close: () => close(server), - host: options.host, - port, - } -} - -function listen(server: Server, port: number, host: string): Promise { - return new Promise((resolve, reject) => { - server.once("error", reject) - server.listen(port, host, () => { - server.off("error", reject) - resolve() - }) - }) -} - -function close(server: Server): Promise { - return new Promise((resolve, reject) => { - server.close((error) => { - if (error !== undefined) { - reject(error) - return - } - resolve() - }) - }) -} - -function assertNever(value: never): never { - throw new ProtocolError(`unexpected command ${JSON.stringify(value)}`) -} diff --git a/src/sql/catalog.ts b/src/sql/catalog.ts deleted file mode 100644 index 45ed400..0000000 --- a/src/sql/catalog.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { SqlResult } from "../types.js" - -const CATALOG_PATTERNS = [ - /current_database\s*\(/i, - /current_schema\s*\(/i, - /pg_catalog\./i, - /information_schema\./i, -] as const - -export function maybeCatalogResult(sql: string): SqlResult | null { - const normalized = sql.trim() - if (/^select\s+version\s*\(\s*\)/i.test(normalized)) { - return { - command: "SELECT", - rowCount: 1, - rows: [{ version: "GitDB PostgreSQL-compatible facade 0.1.0" }], - } - } - if (/^select\s+current_database\s*\(\s*\)/i.test(normalized)) { - return { command: "SELECT", rowCount: 1, rows: [{ current_database: "main" }] } - } - if (/^select\s+current_schema\s*\(\s*\)/i.test(normalized)) { - return { command: "SELECT", rowCount: 1, rows: [{ current_schema: "public" }] } - } - if (CATALOG_PATTERNS.some((pattern) => pattern.test(normalized))) { - return { command: "SELECT", rowCount: 0, rows: [] } - } - return null -} diff --git a/src/sql/compaction.ts b/src/sql/compaction.ts new file mode 100644 index 0000000..6fc7667 --- /dev/null +++ b/src/sql/compaction.ts @@ -0,0 +1,54 @@ +import { SqlExecutionError } from "../errors.js" +import type { GitDbCompactionResult, GitDbStore } from "../storage/store.js" +import type { GitDbManifest, VisibleDatabaseSnapshot } from "../types.js" +import { nextManifestMetadata } from "./manifest-metadata.js" + +type EngineCompactionInput = { + readonly manifest: GitDbManifest + readonly snapshot: VisibleDatabaseSnapshot + readonly store: GitDbStore +} + +export async function compactEngineState( + input: EngineCompactionInput, +): Promise { + if (input.manifest.logSegments.length === 0) { + return { deletedSegments: [], manifest: input.manifest } + } + if (input.store.writeVisibleSnapshot === undefined || input.store.compact === undefined) { + throw new SqlExecutionError("COMPACT", "store does not support visible snapshot compaction") + } + await input.store.writeVisibleSnapshot(input.snapshot) + const manifest = compactedManifest(input.manifest) + await writeManifest(input.store, input.manifest.sequence, manifest) + const result = await input.store.compact({ + compactThroughSequence: input.manifest.sequence, + obsoleteSegments: input.manifest.logSegments, + }) + return { deletedSegments: result.deletedSegments, manifest } +} + +function compactedManifest(manifest: GitDbManifest): GitDbManifest { + return { + ...manifest, + logSegments: [], + metadata: nextManifestMetadata(manifest), + updatedAt: new Date().toISOString(), + version: 2 as const, + } +} + +async function writeManifest( + store: GitDbStore, + expectedSequence: number, + manifest: GitDbManifest, +): Promise { + if (store.compareAndSwapManifest === undefined) { + await store.writeManifest(manifest) + return + } + const swapped = await store.compareAndSwapManifest({ expectedSequence, manifest }) + if (!swapped) { + throw new SqlExecutionError("COMPACT", "manifest changed during compaction") + } +} diff --git a/src/sql/database.ts b/src/sql/database.ts new file mode 100644 index 0000000..b7adbbc --- /dev/null +++ b/src/sql/database.ts @@ -0,0 +1,78 @@ +import alasql from "alasql" +import { z } from "zod" +import { SqlExecutionError } from "../errors.js" +import type { VisibleDatabaseSnapshot } from "../types.js" + +export type AlaSqlDatabase = InstanceType + +const AlaSqlColumnSchema = z.object({ + columnid: z.string().min(1), +}) + +const AlaSqlTableSchema = z.object({ + columns: z.array(AlaSqlColumnSchema), + data: z.array(z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))), +}) + +const AlaSqlTablesSchema = z.record(z.string(), AlaSqlTableSchema) + +let databaseCounter = 0 + +export function createDatabase(): AlaSqlDatabase { + databaseCounter += 1 + return new alasql.Database(`gitdb_${databaseCounter}`) +} + +export function databaseFromSnapshot(snapshot: VisibleDatabaseSnapshot): AlaSqlDatabase { + const db = createDatabase() + restoreSnapshot(db, snapshot) + return db +} + +export function snapshotFromDatabase( + db: AlaSqlDatabase, + sequence: number, +): VisibleDatabaseSnapshot { + const parsed = AlaSqlTablesSchema.parse(db.tables) + return { + sequence, + tables: Object.entries(parsed).map(([name, table]) => ({ + columns: table.columns.map((column) => column.columnid), + name, + rows: table.data, + })), + } +} + +export function restoreSnapshot(db: AlaSqlDatabase, snapshot: VisibleDatabaseSnapshot): void { + for (const table of snapshot.tables) { + const columns = table.columns.map((column) => `${column} STRING`).join(", ") + execOn(db, `CREATE TABLE IF NOT EXISTS ${table.name} (${columns})`, `restore ${table.name}`) + for (const row of table.rows) { + const values = table.columns.map((column) => sqlLiteral(row[column] ?? null)).join(", ") + execOn(db, `INSERT INTO ${table.name} VALUES (${values})`, `restore ${table.name}`) + } + } +} + +export function execOn(db: AlaSqlDatabase, normalizedSql: string, originalSql: string): unknown { + try { + return db.exec(normalizedSql) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new SqlExecutionError(originalSql, detail) + } +} + +function sqlLiteral(value: string | number | boolean | null): string { + if (value === null) { + return "NULL" + } + if (typeof value === "number") { + return value.toString() + } + if (typeof value === "boolean") { + return value ? "TRUE" : "FALSE" + } + return `'${value.replaceAll("'", "''")}'` +} diff --git a/src/sql/engine-options.ts b/src/sql/engine-options.ts new file mode 100644 index 0000000..61a9a47 --- /dev/null +++ b/src/sql/engine-options.ts @@ -0,0 +1,9 @@ +import type { GitDbStore } from "../storage/store.js" +import type { GitDbDurabilityMode } from "../types.js" +import type { SnapshotPolicy } from "./snapshot-policy.js" + +export type GitDbEngineOptions = { + readonly durability?: GitDbDurabilityMode + readonly snapshotPolicy?: SnapshotPolicy + readonly store: GitDbStore +} diff --git a/src/sql/engine.ts b/src/sql/engine.ts index b516ec5..2664ca1 100644 --- a/src/sql/engine.ts +++ b/src/sql/engine.ts @@ -1,42 +1,58 @@ -import alasql from "alasql" -import { z } from "zod" -import { SqlExecutionError } from "../errors.js" -import type { GitDbStore } from "../storage/store.js" -import type { - GitDbManifest, - PersistedMutation, - SqlResult, - VisibleDatabaseSnapshot, +import { + commitBatchThroughStore, + type GitDbCompactionResult, + type GitDbStore, +} from "../storage/store.js" +import { + type GitDbDurabilityMode, + type GitDbManifest, + type PersistedMutation, + type SqlResult, + segmentId, } from "../types.js" -import { maybeCatalogResult } from "./catalog.js" -import { commandTag, isMutation, isTransactionControl, normalizePostgresSql } from "./normalize.js" +import { compactEngineState } from "./compaction.js" +import { + type AlaSqlDatabase, + createDatabase, + databaseFromSnapshot, + execOn, + restoreSnapshot, + snapshotFromDatabase, +} from "./database.js" +import type { GitDbEngineOptions } from "./engine-options.js" +import { EqualityIndexRuntime, type EqualityIndexStats } from "./equality-index.js" +import { nextManifestMetadata } from "./manifest-metadata.js" +import { commandTag, isMutation, isTransactionControl, normalizeGitDbSql } from "./normalize.js" import { toRows } from "./rows.js" +import { type SnapshotPolicy, shouldPersistVisibleSnapshot } from "./snapshot-policy.js" +import { EngineTransaction, type GitDbTransaction } from "./transaction.js" -type GitDbEngineOptions = { - readonly store: GitDbStore -} - -const AlaSqlColumnSchema = z.object({ - columnid: z.string().min(1), -}) - -const AlaSqlTableSchema = z.object({ - columns: z.array(AlaSqlColumnSchema), - data: z.array(z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))), -}) - -const AlaSqlTablesSchema = z.record(z.string(), AlaSqlTableSchema) +export type { GitDbDurabilityMode } from "../types.js" +export type { GitDbEngineOptions } from "./engine-options.js" +export type { SnapshotPolicy } from "./snapshot-policy.js" +export type { GitDbTransaction } from "./transaction.js" export class GitDbEngine { - readonly #db: InstanceType + #db: AlaSqlDatabase + readonly #durability: GitDbDurabilityMode readonly #store: GitDbStore + readonly #snapshotPolicy: SnapshotPolicy #manifest: GitDbManifest + readonly #indexes = new EqualityIndexRuntime() + #mutationQueue = Promise.resolve() + #lastVisibleSnapshotError: unknown = undefined - private constructor(store: GitDbStore, manifest: GitDbManifest) { + private constructor( + store: GitDbStore, + manifest: GitDbManifest, + snapshotPolicy: SnapshotPolicy, + durability: GitDbDurabilityMode, + ) { this.#store = store this.#manifest = manifest - this.#db = new alasql.Database("gitdb") - alasql.options.postgres = true + this.#snapshotPolicy = snapshotPolicy + this.#durability = durability + this.#db = createDatabase() } static async open(options: GitDbEngineOptions): Promise { @@ -50,7 +66,12 @@ export class GitDbEngine { updatedAt: now, logSegments: [], } satisfies GitDbManifest) - const engine = new GitDbEngine(options.store, manifest) + const engine = new GitDbEngine( + options.store, + manifest, + options.snapshotPolicy ?? { mode: "everyMutation" }, + options.durability ?? "sync", + ) const restoredSnapshot = await engine.#restoreVisibleSnapshot() if (!restoredSnapshot) { await engine.#replay() @@ -58,27 +79,95 @@ export class GitDbEngine { if (manifest.sequence === 0) { await options.store.writeManifest(manifest) } + engine.#rebuildIndexes() return engine } async execute(sql: string): Promise { - const catalog = maybeCatalogResult(sql) - if (catalog !== null) { - return catalog - } if (isTransactionControl(sql)) { return { command: commandTag(sql, 0), rowCount: 0, rows: [] } } - const normalized = normalizePostgresSql(sql) - const result = this.#exec(normalized, sql) - const rows = toRows(result) - const rowCount = rows.length > 0 ? rows.length : typeof result === "number" ? result : 0 - const response = { command: commandTag(sql, rowCount), rowCount, rows } satisfies SqlResult + const normalized = normalizeGitDbSql(sql) if (isMutation(sql)) { - await this.#persist(sql) - await this.#persistVisibleSnapshot() + return await this.#executeSingleMutation(normalized, sql) } - return response + const indexedRows = this.#indexes.lookup(normalized) + if (indexedRows !== null) { + return { + command: commandTag(sql, indexedRows.length), + rowCount: indexedRows.length, + rows: indexedRows, + } + } + const result = execOn(this.#db, normalized, sql) + const rows = toRows(result) + const rowCount = rows.length > 0 ? rows.length : typeof result === "number" ? result : 0 + return { command: commandTag(sql, rowCount), rowCount, rows } + } + + async transaction(work: (transaction: GitDbTransaction) => Promise): Promise { + return await this.#enqueueMutation(async () => { + const baseSnapshot = snapshotFromDatabase(this.#db, this.#manifest.sequence) + const transactionDb = databaseFromSnapshot(baseSnapshot) + const transaction = new EngineTransaction(transactionDb) + const value = await work(transaction) + const mutations = transaction.mutations() + if (mutations.length === 0) { + return value + } + await this.#persistMany(mutations) + this.#db = transactionDb + this.#rebuildIndexes() + if (shouldPersistVisibleSnapshot(this.#snapshotPolicy, this.#manifest.sequence)) { + await this.#persistVisibleSnapshotBestEffort() + } + return value + }) + } + + async #executeSingleMutation(normalizedSql: string, originalSql: string): Promise { + return await this.#enqueueMutation(async () => { + const committedManifest = this.#manifest + try { + const result = execOn(this.#db, normalizedSql, originalSql) + const rows = toRows(result) + const rowCount = rows.length > 0 ? rows.length : typeof result === "number" ? result : 0 + await this.#persistMany([originalSql]) + this.#rebuildIndexes() + if (shouldPersistVisibleSnapshot(this.#snapshotPolicy, this.#manifest.sequence)) { + await this.#persistVisibleSnapshotBestEffort() + } + return { command: commandTag(originalSql, rowCount), rowCount, rows } + } catch (error: unknown) { + await this.#restoreCommittedState(committedManifest) + throw error + } + }) + } + + getLastVisibleSnapshotError(): unknown | undefined { + return this.#lastVisibleSnapshotError + } + + getDurabilityMode(): GitDbDurabilityMode { + return this.#durability + } + + getIndexStats(): EqualityIndexStats { + return this.#indexes.stats() + } + + async compact(): Promise { + return await this.#enqueueMutation(async () => { + const result = await compactEngineState({ + manifest: this.#manifest, + snapshot: snapshotFromDatabase(this.#db, this.#manifest.sequence), + store: this.#store, + }) + this.#manifest = result.manifest + this.#rebuildIndexes() + return result + }) } async #restoreVisibleSnapshot(): Promise { @@ -89,14 +178,13 @@ export class GitDbEngine { if (snapshot === null) { return false } - for (const table of snapshot.tables) { - const columns = table.columns.map((column) => `${column} STRING`).join(", ") - this.#exec(`CREATE TABLE IF NOT EXISTS ${table.name} (${columns})`, `restore ${table.name}`) - for (const row of table.rows) { - const values = table.columns.map((column) => sqlLiteral(row[column] ?? null)).join(", ") - this.#exec(`INSERT INTO ${table.name} VALUES (${values})`, `restore ${table.name}`) - } + if (snapshot.sequence === undefined && this.#manifest.sequence > 0) { + return false + } + if (snapshot.sequence !== undefined && snapshot.sequence !== this.#manifest.sequence) { + return false } + restoreSnapshot(this.#db, snapshot) return true } @@ -104,60 +192,73 @@ export class GitDbEngine { if (this.#store.writeVisibleSnapshot === undefined) { return } - await this.#store.writeVisibleSnapshot(this.#visibleSnapshot()) + await this.#store.writeVisibleSnapshot(snapshotFromDatabase(this.#db, this.#manifest.sequence)) } - #visibleSnapshot(): VisibleDatabaseSnapshot { - const parsed = AlaSqlTablesSchema.parse(this.#db.tables) - return { - tables: Object.entries(parsed).map(([name, table]) => ({ - columns: table.columns.map((column) => column.columnid), - name, - rows: table.data, - })), + async #persistVisibleSnapshotBestEffort(): Promise { + try { + await this.#persistVisibleSnapshot() + this.#lastVisibleSnapshotError = undefined + } catch (error: unknown) { + this.#lastVisibleSnapshotError = error } } async #replay(): Promise { const mutations = await this.#store.readMutations(this.#manifest.logSegments) for (const mutation of mutations) { - this.#exec(normalizePostgresSql(mutation.sql), mutation.sql) + execOn(this.#db, normalizeGitDbSql(mutation.sql), mutation.sql) } } - async #persist(sql: string): Promise { - const nextSequence = this.#manifest.sequence + 1 - const at = new Date().toISOString() - const mutation = { at, sequence: nextSequence, sql } satisfies PersistedMutation - const segment = await this.#store.appendMutation(mutation) - this.#manifest = { - ...this.#manifest, - sequence: nextSequence, - updatedAt: at, - logSegments: [...this.#manifest.logSegments, segment], + async #persistMany(sqlStatements: readonly string[]): Promise { + let sequence = this.#manifest.sequence + const logSegments = [...this.#manifest.logSegments] + let updatedAt = this.#manifest.updatedAt + const mutations: PersistedMutation[] = [] + for (const sql of sqlStatements) { + sequence += 1 + updatedAt = new Date().toISOString() + const mutation = { at: updatedAt, sequence, sql } satisfies PersistedMutation + mutations.push(mutation) + logSegments.push(segmentId(sequence.toString().padStart(20, "0"))) } - await this.#store.writeManifest(this.#manifest) + const nextManifest = { + ...this.#manifest, + logSegments, + metadata: nextManifestMetadata(this.#manifest), + sequence, + updatedAt, + version: 2 as const, + } satisfies GitDbManifest + const result = await commitBatchThroughStore(this.#store, { + compareAndSwap: { expectedSequence: this.#manifest.sequence }, + manifest: nextManifest, + mutations, + }) + this.#manifest = result.manifest } - #exec(normalizedSql: string, originalSql: string): unknown { - try { - return this.#db.exec(normalizedSql) - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - throw new SqlExecutionError(originalSql, detail) + async #restoreCommittedState(manifest: GitDbManifest): Promise { + this.#manifest = manifest + this.#db = createDatabase() + const restoredSnapshot = await this.#restoreVisibleSnapshot() + if (!restoredSnapshot) { + await this.#replay() } + this.#rebuildIndexes() } -} -function sqlLiteral(value: string | number | boolean | null): string { - if (value === null) { - return "NULL" + async #enqueueMutation(operation: () => Promise): Promise { + const queued = this.#mutationQueue.then(operation, operation) + this.#mutationQueue = queued.then( + () => undefined, + () => undefined, + ) + return await queued } - if (typeof value === "number") { - return value.toString() - } - if (typeof value === "boolean") { - return value ? "TRUE" : "FALSE" + + #rebuildIndexes(): void { + this.#indexes.rebuild(snapshotFromDatabase(this.#db, this.#manifest.sequence)) } - return `'${value.replaceAll("'", "''")}'` } diff --git a/src/sql/equality-index.ts b/src/sql/equality-index.ts new file mode 100644 index 0000000..ecd396a --- /dev/null +++ b/src/sql/equality-index.ts @@ -0,0 +1,183 @@ +import type { + JsonPrimitive, + SqlRow, + VisibleDatabaseSnapshot, + VisibleTableSnapshot, +} from "../types.js" + +export type EqualityIndexStats = { + readonly indexedRows: number + readonly lookupHits: number + readonly rebuilds: number +} + +type EqualityFilter = { + readonly column: string + readonly value: JsonPrimitive +} + +type IndexedSelectQuery = { + readonly filters: readonly EqualityFilter[] + readonly tableName: string +} + +type TableIndex = { + readonly byColumn: ReadonlyMap> + readonly rows: readonly SqlRow[] +} + +type LiteralParseResult = + | { readonly kind: "invalid" } + | { readonly kind: "value"; readonly value: JsonPrimitive } + +const SELECT_PATTERN = /^SELECT\s+\*\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+WHERE\s+(.+))?\s*$/i + +const CONDITION_PATTERN = + /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(NULL|TRUE|FALSE|-?\d+(?:\.\d+)?|'(?:''|[^'])*')$/i + +export class EqualityIndexRuntime { + #indexedRows = 0 + #lookupHits = 0 + #rebuilds = 0 + #tables: ReadonlyMap = new Map() + + rebuild(snapshot: VisibleDatabaseSnapshot): void { + const tables = new Map() + let indexedRows = 0 + for (const table of snapshot.tables) { + tables.set(table.name, tableIndex(table)) + indexedRows += table.rows.length + } + this.#tables = tables + this.#indexedRows = indexedRows + this.#rebuilds += 1 + } + + lookup(sql: string): readonly SqlRow[] | null { + const query = parseIndexedSelect(sql) + if (query === null || query.filters.length === 0) { + return null + } + const table = this.#tables.get(query.tableName) + if (table === undefined) { + return null + } + const candidates = candidateRows(table, query.filters) + if (candidates === null) { + return null + } + this.#lookupHits += 1 + return candidates + .map((rowIndex) => table.rows[rowIndex]) + .filter((row): row is SqlRow => row !== undefined && matchesFilters(row, query.filters)) + } + + stats(): EqualityIndexStats { + return { + indexedRows: this.#indexedRows, + lookupHits: this.#lookupHits, + rebuilds: this.#rebuilds, + } + } +} + +function tableIndex(table: VisibleTableSnapshot): TableIndex { + const byColumn = new Map>() + for (const column of table.columns) { + byColumn.set(column, new Map()) + } + table.rows.forEach((row, rowIndex) => { + for (const column of table.columns) { + const value = row[column] + const columnIndex = byColumn.get(column) + if (value === undefined || columnIndex === undefined) { + continue + } + const key = indexKey(value) + const bucket = columnIndex.get(key) ?? [] + bucket.push(rowIndex) + columnIndex.set(key, bucket) + } + }) + return { byColumn, rows: table.rows } +} + +function candidateRows( + table: TableIndex, + filters: readonly EqualityFilter[], +): readonly number[] | null { + let selected: readonly number[] | null = null + for (const filter of filters) { + const columnIndex = table.byColumn.get(filter.column) + if (columnIndex === undefined) { + continue + } + const bucket = columnIndex.get(indexKey(filter.value)) ?? [] + if (selected === null || bucket.length < selected.length) { + selected = bucket + } + } + return selected +} + +function matchesFilters(row: SqlRow, filters: readonly EqualityFilter[]): boolean { + return filters.every((filter) => row[filter.column] === filter.value) +} + +function parseIndexedSelect(sql: string): IndexedSelectQuery | null { + const match = SELECT_PATTERN.exec(sql.trim()) + const tableName = match?.[1] + const where = match?.[2] + if (tableName === undefined || where === undefined) { + return null + } + const filters = parseWhere(where) + return filters === null ? null : { filters, tableName } +} + +function parseWhere(where: string): readonly EqualityFilter[] | null { + const filters: EqualityFilter[] = [] + for (const condition of where.split(/\s+AND\s+/i)) { + const filter = parseCondition(condition) + if (filter === null) { + return null + } + filters.push(filter) + } + return filters +} + +function parseCondition(condition: string): EqualityFilter | null { + const match = CONDITION_PATTERN.exec(condition.trim()) + const column = match?.[1] + const literal = match?.[2] + if (column === undefined || literal === undefined) { + return null + } + const parsed = parseLiteral(literal) + return parsed.kind === "invalid" ? null : { column, value: parsed.value } +} + +function parseLiteral(literal: string): LiteralParseResult { + if (/^NULL$/i.test(literal)) { + return { kind: "value", value: null } + } + if (/^TRUE$/i.test(literal)) { + return { kind: "value", value: true } + } + if (/^FALSE$/i.test(literal)) { + return { kind: "value", value: false } + } + if (literal.startsWith("'") && literal.endsWith("'")) { + return { kind: "value", value: literal.slice(1, -1).replaceAll("''", "'") } + } + const value = Number(literal) + return Number.isFinite(value) ? { kind: "value", value } : { kind: "invalid" } +} + +function indexKey(value: JsonPrimitive): string { + if (value === null) { + return "null:" + } + return `${typeof value}:${String(value)}` +} diff --git a/src/sql/manifest-metadata.ts b/src/sql/manifest-metadata.ts new file mode 100644 index 0000000..66bb552 --- /dev/null +++ b/src/sql/manifest-metadata.ts @@ -0,0 +1,8 @@ +import type { GitDbManifest, GitDbManifestMetadata } from "../types.js" + +export function nextManifestMetadata(manifest: GitDbManifest): GitDbManifestMetadata { + if (manifest.version === 1) { + return { migratedFrom: 1, storageEngine: "gitdb" } + } + return manifest.metadata ?? { storageEngine: "gitdb" } +} diff --git a/src/sql/normalize.ts b/src/sql/normalize.ts index 6d7880c..13232c0 100644 --- a/src/sql/normalize.ts +++ b/src/sql/normalize.ts @@ -1,4 +1,4 @@ -export function normalizePostgresSql(sql: string): string { +export function normalizeGitDbSql(sql: string): string { return sql .replaceAll(/"/g, "") .replaceAll(/\bSERIAL\b/gi, "INT") diff --git a/src/sql/snapshot-policy.ts b/src/sql/snapshot-policy.ts new file mode 100644 index 0000000..5609351 --- /dev/null +++ b/src/sql/snapshot-policy.ts @@ -0,0 +1,23 @@ +import { SqlExecutionError } from "../errors.js" + +export type SnapshotPolicy = + | { readonly mode: "everyMutation" } + | { readonly mode: "interval"; readonly mutations: number } + +export function shouldPersistVisibleSnapshot(policy: SnapshotPolicy, sequence: number): boolean { + switch (policy.mode) { + case "everyMutation": + return true + case "interval": + return sequence % policy.mutations === 0 + default: + return assertNever(policy) + } +} + +function assertNever(value: never): never { + throw new SqlExecutionError( + "snapshot policy", + `unexpected snapshot policy ${JSON.stringify(value)}`, + ) +} diff --git a/src/sql/transaction.ts b/src/sql/transaction.ts new file mode 100644 index 0000000..b0bf480 --- /dev/null +++ b/src/sql/transaction.ts @@ -0,0 +1,36 @@ +import type { SqlResult } from "../types.js" +import type { AlaSqlDatabase } from "./database.js" +import { execOn } from "./database.js" +import { commandTag, isMutation, isTransactionControl, normalizeGitDbSql } from "./normalize.js" +import { toRows } from "./rows.js" + +export interface GitDbTransaction { + execute(sql: string): Promise +} + +export class EngineTransaction implements GitDbTransaction { + readonly #db: AlaSqlDatabase + readonly #mutations: string[] = [] + + constructor(db: AlaSqlDatabase) { + this.#db = db + } + + async execute(sql: string): Promise { + if (isTransactionControl(sql)) { + return { command: commandTag(sql, 0), rowCount: 0, rows: [] } + } + const normalized = normalizeGitDbSql(sql) + const result = execOn(this.#db, normalized, sql) + const rows = toRows(result) + const rowCount = rows.length > 0 ? rows.length : typeof result === "number" ? result : 0 + if (isMutation(sql)) { + this.#mutations.push(sql) + } + return { command: commandTag(sql, rowCount), rowCount, rows } + } + + mutations(): readonly string[] { + return this.#mutations + } +} diff --git a/src/storage/atomic-write.ts b/src/storage/atomic-write.ts new file mode 100644 index 0000000..1be694c --- /dev/null +++ b/src/storage/atomic-write.ts @@ -0,0 +1,102 @@ +import { randomUUID } from "node:crypto" +import { mkdir, open, rename, rm } from "node:fs/promises" +import { basename, dirname, join } from "node:path" + +export type AtomicWriteStage = + | "afterTempWrite" + | "afterFileSync" + | "beforeRename" + | "afterRename" + | "afterDirectorySync" + +export type AtomicWriteOptions = { + readonly onStage?: (stage: AtomicWriteStage) => Promise | void +} + +export type AtomicWriteReceipt = { + readonly directorySync: "synced" | "unsupported" + readonly path: string +} + +type AtomicWriteData = string | Uint8Array + +export async function writeFileAtomically( + path: string, + data: AtomicWriteData, + options: AtomicWriteOptions = {}, +): Promise { + const directory = dirname(path) + await mkdir(directory, { recursive: true }) + const tempPath = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`) + let handle: Awaited> | undefined + let renamed = false + try { + handle = await open(tempPath, "wx") + await handle.writeFile(data) + await options.onStage?.("afterTempWrite") + await handle.sync() + await options.onStage?.("afterFileSync") + await handle.close() + handle = undefined + await options.onStage?.("beforeRename") + await rename(tempPath, path) + renamed = true + await options.onStage?.("afterRename") + const directorySync = await syncDirectory(directory) + await options.onStage?.("afterDirectorySync") + return { directorySync, path } + } catch (error: unknown) { + if (handle !== undefined) { + await closeBestEffort(handle) + } + if (!renamed) { + await removeBestEffort(tempPath) + } + throw error + } +} + +async function syncDirectory(path: string): Promise { + let handle: Awaited> | undefined + try { + handle = await open(path, "r") + await handle.sync() + return "synced" + } catch (error: unknown) { + if (isUnsupportedDirectorySync(error)) { + return "unsupported" + } + throw error + } finally { + if (handle !== undefined) { + await handle.close() + } + } +} + +async function closeBestEffort(handle: Awaited>): Promise { + try { + await handle.close() + } catch (error: unknown) { + if (!(error instanceof Error)) { + throw error + } + } +} + +async function removeBestEffort(path: string): Promise { + try { + await rm(path, { force: true }) + } catch (error: unknown) { + if (!(error instanceof Error)) { + throw error + } + } +} + +function isUnsupportedDirectorySync(error: unknown): boolean { + if (!(error instanceof Error) || !("code" in error) || typeof error.code !== "string") { + return false + } + return ["EACCES", "EISDIR", "EINVAL", "ENOTSUP", "EPERM"].includes(error.code) +} diff --git a/src/storage/local-encrypted-store.ts b/src/storage/local-encrypted-store.ts index 513f37e..a3761d9 100644 --- a/src/storage/local-encrypted-store.ts +++ b/src/storage/local-encrypted-store.ts @@ -1,17 +1,41 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises" -import { dirname, join } from "node:path" +import { readFile } from "node:fs/promises" +import { join } from "node:path" import { z } from "zod" import type { Cipher } from "../crypto/aes-gcm.js" import { GitDbStorageError } from "../errors.js" -import { type GitDbManifest, type PersistedMutation, type SegmentId, segmentId } from "../types.js" -import type { GitDbStore } from "./store.js" +import { + type GitDbManifest, + type GitDbManifestMetadata, + type PersistedMutation, + type SegmentId, + segmentId, +} from "../types.js" +import { writeFileAtomically } from "./atomic-write.js" +import { acquireLocalStoreLock } from "./local-lock.js" +import { + commitBatchWithV1Store, + type GitDbCheckpoint, + type GitDbCommitBatch, + type GitDbCommitResult, + type GitDbLock, + type GitDbLockInput, + type GitDbStore, + type ManifestCompareAndSwap, + storeCapabilities, +} from "./store.js" const ManifestSchema = z.object({ - version: z.literal(1), + version: z.union([z.literal(1), z.literal(2)]), sequence: z.number().int().nonnegative(), createdAt: z.string(), updatedAt: z.string(), logSegments: z.array(z.string()), + metadata: z + .object({ + migratedFrom: z.literal(1).optional(), + storageEngine: z.literal("gitdb"), + }) + .optional(), }) const MutationSchema = z.object({ @@ -26,6 +50,7 @@ type LocalEncryptedStoreOptions = { } export class LocalEncryptedStore implements GitDbStore { + readonly capabilities = storeCapabilities({ atomicLocal: true, encrypted: true, locks: true }) readonly #root: string readonly #cipher: Cipher @@ -35,14 +60,24 @@ export class LocalEncryptedStore implements GitDbStore { } async readManifest(): Promise { - const payload = await this.#readNullable(join(this.#root, "manifest.enc")) + const path = join(this.#root, "manifest.enc") + const payload = await this.#readNullable(path) if (payload === null) { return null } - const parsed = ManifestSchema.parse(JSON.parse(this.#cipher.open(payload).toString("utf8"))) - return { - ...parsed, - logSegments: parsed.logSegments.map(segmentId), + try { + const parsed = ManifestSchema.parse(JSON.parse(this.#cipher.open(payload).toString("utf8"))) + const manifest = { + createdAt: parsed.createdAt, + logSegments: parsed.logSegments.map(segmentId), + sequence: parsed.sequence, + updatedAt: parsed.updatedAt, + version: parsed.version, + } + const metadata = normalizeManifestMetadata(parsed.metadata) + return metadata === undefined ? manifest : { ...manifest, metadata } + } catch (error) { + throw manifestReadError(path, error) } } @@ -69,10 +104,37 @@ export class LocalEncryptedStore implements GitDbStore { return mutations } + async commitBatch(batch: GitDbCommitBatch): Promise { + const lock = await this.acquireLock({ timeoutMs: 0 }) + try { + return await commitBatchWithV1Store(this, batch) + } finally { + await lock.release() + } + } + + async compareAndSwapManifest(input: ManifestCompareAndSwap): Promise { + const current = await this.readManifest() + if ((current?.sequence ?? null) !== input.expectedSequence) { + return false + } + await this.writeManifest(input.manifest) + return true + } + + async acquireLock(input?: GitDbLockInput): Promise { + return await acquireLocalStoreLock(join(this.#root, "writer.lock"), input) + } + + async writeCheckpoint(checkpoint: GitDbCheckpoint): Promise { + await this.#writeEncrypted(join(this.#root, "checkpoint.enc"), { + sequence: checkpoint.sequence, + }) + } + async #writeEncrypted(path: string, value: unknown): Promise { - await mkdir(dirname(path), { recursive: true }) const plaintext = Buffer.from(JSON.stringify(value), "utf8") - await writeFile(path, this.#cipher.seal(plaintext), "utf8") + await writeFileAtomically(path, this.#cipher.seal(plaintext)) } async #readNullable(path: string): Promise { @@ -86,3 +148,19 @@ export class LocalEncryptedStore implements GitDbStore { } } } + +function manifestReadError(path: string, error: unknown): GitDbStorageError { + const detail = error instanceof Error ? error.message : String(error) + return new GitDbStorageError(`failed to read manifest at ${path}: ${detail}`) +} + +function normalizeManifestMetadata( + metadata: { readonly migratedFrom?: 1 | undefined; readonly storageEngine: "gitdb" } | undefined, +): GitDbManifestMetadata | undefined { + if (metadata === undefined) { + return undefined + } + return metadata.migratedFrom === undefined + ? { storageEngine: metadata.storageEngine } + : { migratedFrom: metadata.migratedFrom, storageEngine: metadata.storageEngine } +} diff --git a/src/storage/local-lock.ts b/src/storage/local-lock.ts new file mode 100644 index 0000000..b4385ec --- /dev/null +++ b/src/storage/local-lock.ts @@ -0,0 +1,176 @@ +import { randomUUID } from "node:crypto" +import { mkdir, open, readFile, rm } from "node:fs/promises" +import { hostname } from "node:os" +import { dirname } from "node:path" +import { z } from "zod" +import { GitDbLockTimeoutError } from "../errors.js" +import { writeFileAtomically } from "./atomic-write.js" +import type { GitDbLock, GitDbLockInput } from "./store.js" + +const DEFAULT_STALE_AFTER_MS = 30_000 +const DEFAULT_HEARTBEAT_MS = 1_000 + +const LockFileSchema = z.object({ + createdAt: z.string(), + heartbeatAt: z.string().optional(), + hostname: z.string().optional(), + pid: z.number().int().nonnegative().optional(), + token: z.string().min(1), +}) + +type LocalLockRecord = { + readonly createdAt: Date + readonly heartbeatAt: Date + readonly token: string +} + +type LocalLockHeartbeat = { + readonly stop: () => Promise +} + +export async function acquireLocalStoreLock( + path: string, + input: GitDbLockInput = {}, +): Promise { + const token = randomUUID() + const startedAt = Date.now() + const timeoutMs = input.timeoutMs ?? 0 + const staleAfterMs = input.staleAfterMs ?? DEFAULT_STALE_AFTER_MS + await mkdir(dirname(path), { recursive: true }) + while (true) { + try { + await writeLockFile(path, token) + const heartbeat = startHeartbeat( + path, + token, + input.heartbeatMs ?? Math.min(DEFAULT_HEARTBEAT_MS, Math.max(25, staleAfterMs / 2)), + ) + return { + token, + release: async () => { + await heartbeat.stop() + await releaseLock(path, token) + }, + } + } catch (error: unknown) { + if (!isNodeError(error, "EEXIST")) { + throw error + } + if (await removeStaleLock(path, staleAfterMs)) { + continue + } + if (Date.now() - startedAt >= timeoutMs) { + throw new GitDbLockTimeoutError("local store lock is held") + } + await sleep(25) + } + } +} + +async function writeLockFile(path: string, token: string): Promise { + const handle = await open(path, "wx") + try { + await handle.writeFile(JSON.stringify(lockFilePayload(token))) + await handle.sync() + } finally { + await handle.close() + } +} + +function startHeartbeat(path: string, token: string, heartbeatMs: number): LocalLockHeartbeat { + let stopped = false + let pending = Promise.resolve() + const heartbeat = setInterval(() => { + if (stopped) { + return + } + pending = refreshHeartbeat(path, token).catch(() => {}) + }, heartbeatMs) + heartbeat.unref() + return { + stop: async () => { + stopped = true + clearInterval(heartbeat) + await pending + }, + } +} + +async function refreshHeartbeat(path: string, token: string): Promise { + const current = await readLock(path) + if (current?.token !== token) { + return + } + await writeFileAtomically(path, JSON.stringify(lockFilePayload(token, current.createdAt))) +} + +async function releaseLock(path: string, token: string): Promise { + const current = await readLock(path) + if (current?.token !== token) { + return + } + await rm(path, { force: true }) +} + +async function removeStaleLock(path: string, staleAfterMs: number): Promise { + const current = await readLock(path) + if (current === null) { + await rm(path, { force: true }) + return true + } + if (Date.now() - current.heartbeatAt.getTime() < staleAfterMs) { + return false + } + await rm(path, { force: true }) + return true +} + +async function readLock(path: string): Promise { + try { + const parsed = LockFileSchema.safeParse(JSON.parse(await readFile(path, "utf8"))) + if (!parsed.success) { + return null + } + return lockRecordFromFile(parsed.data) + } catch (error: unknown) { + if (isNodeError(error, "ENOENT")) { + return null + } + if (error instanceof SyntaxError) { + return null + } + throw error + } +} + +function lockRecordFromFile(file: z.infer): LocalLockRecord | null { + const createdAt = new Date(file.createdAt) + const heartbeatAt = file.heartbeatAt === undefined ? createdAt : new Date(file.heartbeatAt) + if (Number.isNaN(createdAt.getTime()) || Number.isNaN(heartbeatAt.getTime())) { + return null + } + return { createdAt, heartbeatAt, token: file.token } +} + +function lockFilePayload( + token: string, + createdAt: Date = new Date(), +): z.infer { + return { + createdAt: createdAt.toISOString(), + heartbeatAt: new Date().toISOString(), + hostname: hostname(), + pid: process.pid, + token, + } +} + +function isNodeError(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} diff --git a/src/storage/local-plaintext-store.ts b/src/storage/local-plaintext-store.ts index c8d5433..b059cb1 100644 --- a/src/storage/local-plaintext-store.ts +++ b/src/storage/local-plaintext-store.ts @@ -1,5 +1,5 @@ -import { mkdir, readdir, readFile, writeFile } from "node:fs/promises" -import { dirname, join } from "node:path" +import { readFile, unlink } from "node:fs/promises" +import { join } from "node:path" import { GitDbStorageError } from "../errors.js" import type { GitDbManifest, @@ -7,30 +7,63 @@ import type { SegmentId, VisibleDatabaseSnapshot, } from "../types.js" +import { writeFileAtomically } from "./atomic-write.js" +import { acquireLocalStoreLock } from "./local-lock.js" +import { readLocalVisibleSnapshot, writeLocalVisibleSnapshot } from "./local-visible-snapshot.js" import { parsePlaintextManifest, parsePlaintextMutation, - parseVisibleTableRows, - parseVisibleTableSchema, segmentIdForSequence, stringifyPlaintext, } from "./plaintext-codec.js" -import type { GitDbStore } from "./store.js" +import { + type GitDbCheckpoint, + type GitDbCommitBatch, + type GitDbCommitResult, + type GitDbCompactionInput, + type GitDbCompactionResult, + type GitDbLock, + type GitDbLockInput, + type GitDbStore, + type ManifestCompareAndSwap, + storeCapabilities, +} from "./store.js" +import { DEFAULT_VISIBLE_SNAPSHOT_PAGE_SIZE_BYTES } from "./visible-pages.js" + +const MAX_CONCURRENT_BATCH_WRITES = 32 type LocalPlaintextStoreOptions = { readonly root: string + readonly visibleSnapshotPageSizeBytes?: number } export class LocalPlaintextStore implements GitDbStore { + readonly capabilities = storeCapabilities({ + atomicLocal: true, + compaction: true, + locks: true, + visibleSnapshots: true, + }) readonly #root: string + readonly #visibleSnapshotPageSizeBytes: number constructor(options: LocalPlaintextStoreOptions) { this.#root = join(options.root, "gitdb", "v1") + this.#visibleSnapshotPageSizeBytes = + options.visibleSnapshotPageSizeBytes ?? DEFAULT_VISIBLE_SNAPSHOT_PAGE_SIZE_BYTES } async readManifest(): Promise { - const payload = await this.#readNullable(join(this.#root, "manifest.json")) - return payload === null ? null : parsePlaintextManifest(payload) + const path = join(this.#root, "manifest.json") + const payload = await this.#readNullable(path) + if (payload === null) { + return null + } + try { + return parsePlaintextManifest(payload) + } catch (error) { + throw manifestReadError(path, error) + } } async writeManifest(manifest: GitDbManifest): Promise { @@ -55,61 +88,152 @@ export class LocalPlaintextStore implements GitDbStore { return mutations } - async readVisibleSnapshot(): Promise { - const tableNames = await this.#readDirectoryNames() - const tables = [] - for (const tableName of tableNames) { - const schemaPayload = await this.#readNullable(join(this.#root, tableName, "schema.json")) - const dataPayload = await this.#readNullable(join(this.#root, tableName, "data.json")) - if (schemaPayload !== null && dataPayload !== null) { - const schema = parseVisibleTableSchema(schemaPayload) - tables.push({ - ...schema, - rows: parseVisibleTableRows(dataPayload), - }) + async commitBatch(batch: GitDbCommitBatch): Promise { + const lock = await this.acquireLock({ timeoutMs: 0 }) + try { + await this.#assertManifestCompareAndSwap(batch.compareAndSwap?.expectedSequence) + const segments = await appendMutationsConcurrently(this, batch.mutations) + await this.writeManifest(batch.manifest) + return { manifest: batch.manifest, segments } + } finally { + await lock.release() + } + } + + async compareAndSwapManifest(input: ManifestCompareAndSwap): Promise { + const current = await this.readManifest() + if ((current?.sequence ?? null) !== input.expectedSequence) { + return false + } + await this.writeManifest(input.manifest) + return true + } + + async acquireLock(input?: GitDbLockInput): Promise { + return await acquireLocalStoreLock(join(this.#root, "writer.lock"), input) + } + + async writeCheckpoint(checkpoint: GitDbCheckpoint): Promise { + await this.#writeJson(join(this.#root, "checkpoint.json"), { sequence: checkpoint.sequence }) + if (checkpoint.snapshot !== undefined) { + await this.writeVisibleSnapshot({ ...checkpoint.snapshot, sequence: checkpoint.sequence }) + } + } + + async compact(input: GitDbCompactionInput): Promise { + const deletedSegments: SegmentId[] = [] + for (const segment of input.obsoleteSegments) { + if (sequenceForSegment(segment) > input.compactThroughSequence) { + continue + } + const deleted = await this.#deleteSegment(segment) + if (deleted) { + deletedSegments.push(segment) } } - return tables.length === 0 ? null : { tables } + const manifest = await this.readManifest() + if (manifest === null) { + throw new GitDbStorageError("missing manifest after compaction") + } + return { deletedSegments, manifest } + } + + async readVisibleSnapshot(): Promise { + return await readLocalVisibleSnapshot({ + readNullable: (path) => this.#readNullable(path), + root: this.#root, + }) } async writeVisibleSnapshot(snapshot: VisibleDatabaseSnapshot): Promise { - for (const table of snapshot.tables) { - await this.#writeJson(join(this.#root, table.name, "schema.json"), { - columns: table.columns, - name: table.name, - }) - await this.#writeJson(join(this.#root, table.name, "data.json"), table.rows) - } + await writeLocalVisibleSnapshot({ + pageSizeBytes: this.#visibleSnapshotPageSizeBytes, + root: this.#root, + snapshot, + writeJson: (path, value) => this.#writeJson(path, value), + writeJsonIfChanged: (path, value) => this.#writeJsonIfChanged(path, value), + }) } async #writeJson(path: string, value: unknown): Promise { - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, stringifyPlaintext(value), "utf8") + await writeFileAtomically(path, stringifyPlaintext(value)) + } + + async #writeJsonIfChanged(path: string, value: unknown): Promise { + const next = stringifyPlaintext(value) + if ((await this.#readNullable(path)) === next) { + return + } + await writeFileAtomically(path, next) } - async #readDirectoryNames(): Promise { + async #readNullable(path: string): Promise { try { - const entries = await readdir(this.#root, { withFileTypes: true }) - return entries - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .filter((name) => name !== "log") + return await readFile(path, "utf8") } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return [] + return null } throw error } } - async #readNullable(path: string): Promise { + async #deleteSegment(segment: SegmentId): Promise { try { - return await readFile(path, "utf8") + await unlink(join(this.#root, "log", `${segment}.json`)) + return true } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return null + return false } throw error } } + + async #assertManifestCompareAndSwap(expectedSequence: number | null | undefined): Promise { + if (expectedSequence === undefined) { + return + } + const current = await this.readManifest() + const currentSequence = current?.sequence ?? null + if (currentSequence !== expectedSequence) { + throw new GitDbStorageError( + `manifest compare-and-swap failed: expected sequence ${expectedSequence}, got ${currentSequence}`, + ) + } + } +} + +function manifestReadError(path: string, error: unknown): GitDbStorageError { + const detail = error instanceof Error ? error.message : String(error) + return new GitDbStorageError(`failed to read manifest at ${path}: ${detail}`) +} + +function sequenceForSegment(segment: SegmentId): number { + return Number.parseInt(segment, 10) +} + +async function appendMutationsConcurrently( + store: LocalPlaintextStore, + mutations: readonly PersistedMutation[], +): Promise { + const indexedSegments: { readonly index: number; readonly segment: SegmentId }[] = [] + let nextIndex = 0 + const workerCount = Math.min(MAX_CONCURRENT_BATCH_WRITES, mutations.length) + const workers = Array.from({ length: workerCount }, async () => { + for (;;) { + const index = nextIndex + nextIndex += 1 + if (index >= mutations.length) { + return + } + const mutation = mutations[index] + if (mutation === undefined) { + throw new GitDbStorageError(`missing mutation at batch index ${index}`) + } + indexedSegments.push({ index, segment: await store.appendMutation(mutation) }) + } + }) + await Promise.all(workers) + return indexedSegments.sort((left, right) => left.index - right.index).map((item) => item.segment) } diff --git a/src/storage/local-visible-snapshot.ts b/src/storage/local-visible-snapshot.ts new file mode 100644 index 0000000..09ba6b3 --- /dev/null +++ b/src/storage/local-visible-snapshot.ts @@ -0,0 +1,133 @@ +import { readdir } from "node:fs/promises" +import { join } from "node:path" +import type { SqlRow, VisibleDatabaseSnapshot, VisibleTableSnapshot } from "../types.js" +import { + parseVisibleSnapshotCheckpoint, + parseVisibleTablePageIndex, + parseVisibleTableRows, + parseVisibleTableSchema, +} from "./plaintext-codec.js" +import { visibleSnapshotFiles } from "./visible-pages.js" + +type LocalVisibleSnapshotInput = { + readonly readNullable: (path: string) => Promise + readonly root: string +} + +type LocalVisibleSnapshotWriteInput = { + readonly pageSizeBytes: number + readonly root: string + readonly snapshot: VisibleDatabaseSnapshot + readonly writeJson: (path: string, value: unknown) => Promise + readonly writeJsonIfChanged: (path: string, value: unknown) => Promise +} + +type VisibleRowsRead = + | { readonly kind: "invalid" } + | { readonly kind: "missing" } + | { readonly kind: "rows"; readonly rows: VisibleTableSnapshot["rows"] } + +export async function readLocalVisibleSnapshot( + input: LocalVisibleSnapshotInput, +): Promise { + const checkpoint = await readSnapshotCheckpoint(input) + const tables: VisibleTableSnapshot[] = [] + for (const tableName of await readTableNames(input.root)) { + const schemaPayload = await input.readNullable(join(input.root, tableName, "schema.json")) + if (schemaPayload === null) { + continue + } + const rows = await readVisibleRows(input, tableName) + switch (rows.kind) { + case "missing": + continue + case "invalid": + return null + case "rows": + tables.push({ ...parseVisibleTableSchema(schemaPayload), rows: rows.rows }) + break + default: + return assertNever(rows) + } + } + if (tables.length === 0) { + return null + } + return checkpoint === undefined ? { tables } : { sequence: checkpoint, tables } +} + +export async function writeLocalVisibleSnapshot( + input: LocalVisibleSnapshotWriteInput, +): Promise { + for (const file of visibleSnapshotFiles(input.snapshot, input.pageSizeBytes)) { + const path = join(input.root, ...file.path.split("/")) + if (file.path === "snapshot.json") { + await input.writeJson(path, file.value) + } else { + await input.writeJsonIfChanged(path, file.value) + } + } +} + +async function readVisibleRows( + input: LocalVisibleSnapshotInput, + tableName: string, +): Promise { + const pageIndexPayload = await input.readNullable(join(input.root, tableName, "pages.json")) + if (pageIndexPayload !== null) { + return await readPagedRows(input, tableName, pageIndexPayload) + } + const dataPayload = await input.readNullable(join(input.root, tableName, "data.json")) + return dataPayload === null + ? { kind: "missing" } + : { kind: "rows", rows: parseVisibleTableRows(dataPayload) } +} + +async function readPagedRows( + input: LocalVisibleSnapshotInput, + tableName: string, + pageIndexPayload: string, +): Promise { + const pageIndex = parseVisibleTablePageIndex(pageIndexPayload) + const rows: SqlRow[] = [] + for (const page of pageIndex.pages) { + const payload = await input.readNullable(join(input.root, tableName, "pages", page)) + if (payload === null) { + return { kind: "invalid" } + } + rows.push(...parseVisibleTableRows(payload)) + } + if (rows.length < pageIndex.rowCount) { + return { kind: "invalid" } + } + return { kind: "rows", rows: rows.slice(0, pageIndex.rowCount) } +} + +async function readTableNames(root: string): Promise { + try { + const entries = await readdir(root, { withFileTypes: true }) + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => name !== "log") + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return [] + } + throw error + } +} + +async function readSnapshotCheckpoint( + input: LocalVisibleSnapshotInput, +): Promise { + const payload = await input.readNullable(join(input.root, "snapshot.json")) + if (payload === null) { + return undefined + } + return parseVisibleSnapshotCheckpoint(payload) +} + +function assertNever(value: never): never { + throw new TypeError(`unexpected visible rows result ${JSON.stringify(value)}`) +} diff --git a/src/storage/plaintext-codec.ts b/src/storage/plaintext-codec.ts index 51b56ac..18a5936 100644 --- a/src/storage/plaintext-codec.ts +++ b/src/storage/plaintext-codec.ts @@ -1,6 +1,7 @@ import { z } from "zod" import { type GitDbManifest, + type GitDbManifestMetadata, type PersistedMutation, type SegmentId, segmentId, @@ -10,11 +11,17 @@ import { } from "../types.js" const ManifestSchema = z.object({ - version: z.literal(1), + version: z.union([z.literal(1), z.literal(2)]), sequence: z.number().int().nonnegative(), createdAt: z.string(), updatedAt: z.string(), logSegments: z.array(z.string()), + metadata: z + .object({ + migratedFrom: z.literal(1).optional(), + storageEngine: z.literal("gitdb"), + }) + .optional(), }) const MutationSchema = z.object({ @@ -32,20 +39,36 @@ const VisibleTableRowsSchema = z.array( z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])), ) +const VisibleTablePageIndexSchema = z.object({ + pages: z.array(z.string().min(1)), + rowCount: z.number().int().nonnegative(), + version: z.literal(1).optional(), +}) + +const VisibleSnapshotCheckpointSchema = z.object({ + sequence: z.number().int().nonnegative().optional(), +}) + const VisibleTableSnapshotSchema = VisibleTableSchemaSchema.extend({ rows: z.array(z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))), }) const VisibleDatabaseSnapshotSchema = z.object({ + sequence: z.number().int().nonnegative().optional(), tables: z.array(VisibleTableSnapshotSchema), }) export function parsePlaintextManifest(payload: string): GitDbManifest { const parsed = ManifestSchema.parse(JSON.parse(payload)) - return { - ...parsed, + const manifest = { + createdAt: parsed.createdAt, logSegments: parsed.logSegments.map(segmentId), + sequence: parsed.sequence, + updatedAt: parsed.updatedAt, + version: parsed.version, } + const metadata = normalizeManifestMetadata(parsed.metadata) + return metadata === undefined ? manifest : { ...manifest, metadata } } export function parsePlaintextMutation(payload: string): PersistedMutation { @@ -64,8 +87,22 @@ export function parseVisibleTableRows(payload: string): VisibleTableSnapshot["ro return VisibleTableRowsSchema.parse(JSON.parse(payload)) } +export function parseVisibleTablePageIndex(payload: string): { + readonly pages: readonly string[] + readonly rowCount: number +} { + return VisibleTablePageIndexSchema.parse(JSON.parse(payload)) +} + +export function parseVisibleSnapshotCheckpoint(payload: string): number | undefined { + return VisibleSnapshotCheckpointSchema.parse(JSON.parse(payload)).sequence +} + export function parseVisibleDatabaseSnapshot(payload: string): VisibleDatabaseSnapshot { - return VisibleDatabaseSnapshotSchema.parse(JSON.parse(payload)) + const parsed = VisibleDatabaseSnapshotSchema.parse(JSON.parse(payload)) + return parsed.sequence === undefined + ? { tables: parsed.tables } + : { sequence: parsed.sequence, tables: parsed.tables } } export function stringifyPlaintext(value: unknown): string { @@ -75,3 +112,14 @@ export function stringifyPlaintext(value: unknown): string { export function segmentIdForSequence(sequence: number): SegmentId { return segmentId(sequence.toString().padStart(20, "0")) } + +function normalizeManifestMetadata( + metadata: { readonly migratedFrom?: 1 | undefined; readonly storageEngine: "gitdb" } | undefined, +): GitDbManifestMetadata | undefined { + if (metadata === undefined) { + return undefined + } + return metadata.migratedFrom === undefined + ? { storageEngine: metadata.storageEngine } + : { migratedFrom: metadata.migratedFrom, storageEngine: metadata.storageEngine } +} diff --git a/src/storage/store.ts b/src/storage/store.ts index 85ac2ad..02901b4 100644 --- a/src/storage/store.ts +++ b/src/storage/store.ts @@ -1,3 +1,4 @@ +import { GitDbStorageError } from "../errors.js" import type { GitDbManifest, PersistedMutation, @@ -6,10 +7,155 @@ import type { } from "../types.js" export interface GitDbStore { + readonly capabilities?: GitDbStoreCapabilities readManifest(): Promise writeManifest(manifest: GitDbManifest): Promise appendMutation(mutation: PersistedMutation): Promise readMutations(segments: readonly SegmentId[]): Promise readVisibleSnapshot?(): Promise writeVisibleSnapshot?(snapshot: VisibleDatabaseSnapshot): Promise + commitBatch?(batch: GitDbCommitBatch): Promise + compareAndSwapManifest?(input: ManifestCompareAndSwap): Promise + acquireLock?(input?: GitDbLockInput): Promise + writeCheckpoint?(checkpoint: GitDbCheckpoint): Promise + compact?(input: GitDbCompactionInput): Promise +} + +export interface GitDbStoreV2 extends GitDbStore { + readonly capabilities: GitDbStoreCapabilities + commitBatch(batch: GitDbCommitBatch): Promise +} + +export type GitDbStoreCapabilities = { + readonly atomicLocal: boolean + readonly compaction: boolean + readonly encrypted: boolean + readonly locks: boolean + readonly remote: boolean + readonly treeCommits: boolean + readonly visibleSnapshots: boolean +} + +export type ManifestCompareAndSwap = { + readonly expectedSequence: number | null + readonly manifest: GitDbManifest +} + +export type GitDbCommitBatch = { + readonly compareAndSwap?: Pick + readonly manifest: GitDbManifest + readonly mutations: readonly PersistedMutation[] +} + +export type GitDbCommitResult = { + readonly manifest: GitDbManifest + readonly segments: readonly SegmentId[] +} + +export type GitDbLockInput = { + readonly heartbeatMs?: number + readonly staleAfterMs?: number + readonly timeoutMs?: number +} + +export type GitDbLock = { + readonly release: () => Promise + readonly token: string +} + +export type GitDbCheckpoint = { + readonly sequence: number + readonly snapshot?: VisibleDatabaseSnapshot +} + +export type GitDbCompactionInput = { + readonly compactThroughSequence: number + readonly obsoleteSegments: readonly SegmentId[] +} + +export type GitDbCompactionResult = { + readonly deletedSegments: readonly SegmentId[] + readonly manifest: GitDbManifest +} + +export function storeCapabilities( + overrides: Partial = {}, +): GitDbStoreCapabilities { + return { + atomicLocal: false, + compaction: false, + encrypted: false, + locks: false, + remote: false, + treeCommits: false, + visibleSnapshots: false, + ...overrides, + } +} + +export function asGitDbStoreV2(store: GitDbStore): GitDbStoreV2 { + if (isGitDbStoreV2(store)) { + return store + } + const capabilities = store.capabilities ?? storeCapabilities() + const readVisibleSnapshot = store.readVisibleSnapshot?.bind(store) + const writeVisibleSnapshot = store.writeVisibleSnapshot?.bind(store) + const compareAndSwapManifest = store.compareAndSwapManifest?.bind(store) + const acquireLock = store.acquireLock?.bind(store) + const writeCheckpoint = store.writeCheckpoint?.bind(store) + const compact = store.compact?.bind(store) + return { + capabilities, + readManifest: () => store.readManifest(), + writeManifest: (manifest) => store.writeManifest(manifest), + appendMutation: (mutation) => store.appendMutation(mutation), + readMutations: (segments) => store.readMutations(segments), + commitBatch: (batch) => commitBatchWithV1Store(store, batch), + ...(readVisibleSnapshot === undefined ? {} : { readVisibleSnapshot }), + ...(writeVisibleSnapshot === undefined ? {} : { writeVisibleSnapshot }), + ...(compareAndSwapManifest === undefined ? {} : { compareAndSwapManifest }), + ...(acquireLock === undefined ? {} : { acquireLock }), + ...(writeCheckpoint === undefined ? {} : { writeCheckpoint }), + ...(compact === undefined ? {} : { compact }), + } +} + +export async function commitBatchThroughStore( + store: GitDbStore, + batch: GitDbCommitBatch, +): Promise { + return await asGitDbStoreV2(store).commitBatch(batch) +} + +function isGitDbStoreV2(store: GitDbStore): store is GitDbStoreV2 { + return store.capabilities !== undefined && store.commitBatch !== undefined +} + +export async function commitBatchWithV1Store( + store: GitDbStore, + batch: GitDbCommitBatch, +): Promise { + await assertManifestCompareAndSwap(store, batch.compareAndSwap) + const segments: SegmentId[] = [] + for (const mutation of batch.mutations) { + segments.push(await store.appendMutation(mutation)) + } + await store.writeManifest(batch.manifest) + return { manifest: batch.manifest, segments } +} + +async function assertManifestCompareAndSwap( + store: GitDbStore, + compareAndSwap: Pick | undefined, +): Promise { + if (compareAndSwap === undefined) { + return + } + const manifest = await store.readManifest() + const currentSequence = manifest?.sequence ?? null + if (currentSequence !== compareAndSwap.expectedSequence) { + throw new GitDbStorageError( + `manifest compare-and-swap failed: expected sequence ${compareAndSwap.expectedSequence}, got ${currentSequence}`, + ) + } } diff --git a/src/storage/visible-indexes.ts b/src/storage/visible-indexes.ts new file mode 100644 index 0000000..2adf01a --- /dev/null +++ b/src/storage/visible-indexes.ts @@ -0,0 +1,35 @@ +import type { JsonPrimitive, VisibleTableSnapshot } from "../types.js" + +export type VisibleTableIndexArtifact = { + readonly columns: Readonly>>> + readonly rowCount: number + readonly version: 1 +} + +export function visibleTableIndexArtifact(table: VisibleTableSnapshot): VisibleTableIndexArtifact { + const columns: Record> = {} + for (const column of table.columns) { + columns[column] = {} + } + table.rows.forEach((row, rowIndex) => { + for (const column of table.columns) { + const value = row[column] + const values = columns[column] + if (value === undefined || values === undefined) { + continue + } + const key = artifactKey(value) + const rows = values[key] ?? [] + rows.push(rowIndex) + values[key] = rows + } + }) + return { columns, rowCount: table.rows.length, version: 1 } +} + +function artifactKey(value: JsonPrimitive): string { + if (value === null) { + return "null:" + } + return `${typeof value}:${String(value)}` +} diff --git a/src/storage/visible-pages.ts b/src/storage/visible-pages.ts new file mode 100644 index 0000000..e2aab79 --- /dev/null +++ b/src/storage/visible-pages.ts @@ -0,0 +1,88 @@ +import type { VisibleDatabaseSnapshot, VisibleTableSnapshot } from "../types.js" +import { stringifyPlaintext } from "./plaintext-codec.js" +import { visibleTableIndexArtifact } from "./visible-indexes.js" + +export const DEFAULT_VISIBLE_SNAPSHOT_PAGE_SIZE_BYTES = 4 * 1024 * 1024 + +export type VisibleSnapshotFile = { + readonly path: string + readonly value: unknown +} + +export type VisiblePageFile = { + readonly fileName: string + readonly rows: VisibleTableSnapshot["rows"] +} + +export function visibleSnapshotFiles( + snapshot: VisibleDatabaseSnapshot, + pageSizeBytes: number, +): readonly VisibleSnapshotFile[] { + const files = snapshot.tables.flatMap((table) => visibleTableFiles(table, pageSizeBytes)) + if (snapshot.sequence === undefined) { + return files + } + return [...files, { path: "snapshot.json", value: { sequence: snapshot.sequence } }] +} + +export function visibleTableFiles( + table: VisibleTableSnapshot, + pageSizeBytes: number, +): readonly VisibleSnapshotFile[] { + const pages = visibleTablePages(table.rows, pageSizeBytes) + return [ + { + path: `${table.name}/schema.json`, + value: { columns: table.columns, name: table.name }, + }, + ...pages.map((page) => ({ + path: `${table.name}/pages/${page.fileName}`, + value: page.rows, + })), + { + path: `${table.name}/pages.json`, + value: { + pages: pages.map((page) => page.fileName), + rowCount: table.rows.length, + version: 1, + }, + }, + { + path: `${table.name}/indexes.json`, + value: visibleTableIndexArtifact(table), + }, + ] +} + +export function visibleTablePages( + rows: VisibleTableSnapshot["rows"], + pageSizeBytes: number, +): readonly VisiblePageFile[] { + const targetSizeBytes = Math.max(1, Math.floor(pageSizeBytes)) + const pages: VisiblePageFile[] = [] + let currentRows: VisibleTableSnapshot["rows"] = [] + for (const row of rows) { + const candidateRows = [...currentRows, row] + if (currentRows.length > 0 && jsonSize(candidateRows) > targetSizeBytes) { + pages.push(pageFile(pages.length, currentRows)) + currentRows = [row] + } else { + currentRows = candidateRows + } + } + if (currentRows.length > 0) { + pages.push(pageFile(pages.length, currentRows)) + } + return pages +} + +function pageFile(index: number, rows: VisibleTableSnapshot["rows"]): VisiblePageFile { + return { + fileName: `${index.toString().padStart(6, "0")}.json`, + rows, + } +} + +function jsonSize(value: unknown): number { + return Buffer.byteLength(stringifyPlaintext(value), "utf8") +} diff --git a/src/types.ts b/src/types.ts index c555b6d..e871782 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,7 @@ export type VisibleTableSchema = { } export type VisibleDatabaseSnapshot = { + readonly sequence?: number readonly tables: readonly VisibleTableSnapshot[] } @@ -31,18 +32,28 @@ export type SqlResult = { readonly rowCount: number } +export type GitDbDurabilityMode = "local" | "sync" | "background" + export type PersistedMutation = { readonly sequence: number readonly sql: string readonly at: string } +export type GitDbManifestVersion = 1 | 2 + +export type GitDbManifestMetadata = { + readonly migratedFrom?: 1 + readonly storageEngine: "gitdb" +} + export type GitDbManifest = { - readonly version: 1 + readonly version: GitDbManifestVersion readonly sequence: number readonly createdAt: string readonly updatedAt: string readonly logSegments: readonly SegmentId[] + readonly metadata?: GitDbManifestMetadata } export function segmentId(value: string): SegmentId { diff --git a/tests/benchmark-report.test.ts b/tests/benchmark-report.test.ts new file mode 100644 index 0000000..97aaa88 --- /dev/null +++ b/tests/benchmark-report.test.ts @@ -0,0 +1,121 @@ +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { describe, expect, it } from "vitest" +import { z } from "zod" + +const execFileAsync = promisify(execFile) + +const ProbeSchema = z.object({ + comparisonCount: z.number(), + headline: z.string(), + metadataRepeatCount: z.number(), + markdownIncludesOrm: z.boolean(), + runtimeIncludesNode: z.boolean(), + scenarioKeys: z.array(z.string()), +}) + +describe("benchmark report parser", () => { + it("builds comparison evidence from Markdown baseline and JSON current results", async () => { + // Given: documented baseline rows and current JSON benchmark output. + const probe = await runBenchmarkReportProbe() + + // When/Then: comparable scenarios become site-ready evidence. + expect(probe).toEqual({ + comparisonCount: 3, + headline: "Raw engine is 3.00x of ORM writes/s (save() transaction overhead)", + metadataRepeatCount: 2, + markdownIncludesOrm: true, + runtimeIncludesNode: true, + scenarioKeys: ["local-plaintext", "local-encrypted", "orm-save-single"], + }) + }) + + it("rejects benchmark JSON without a results array", async () => { + // Given: malformed benchmark evidence. + const code = [ + "import { parseBenchmarkOutput } from './scripts/benchmark-report.mjs'", + 'try { parseBenchmarkOutput(\'{"results":"bad"}\') }', + "catch (error) { console.log(error instanceof Error ? error.message : String(error)) }", + ].join("\n") + + // When: the parser runs in Node against the real report module. + const { stdout } = await execFileAsync(process.execPath, ["--input-type=module", "-e", code]) + + // Then: the schema error is explicit enough for CI gate diagnostics. + expect(stdout.trim()).toBe("benchmark JSON must be an array or contain a results array") + }) +}) + +async function runBenchmarkReportProbe(): Promise> { + const code = ` + import { + buildBenchmarkEvidence, + formatComparisonMarkdown, + parseBenchmarkEvidence, + parseBenchmarkOutput, + } from './scripts/benchmark-report.mjs' + + const baseline = parseBenchmarkOutput(\` +| Scenario | Rows | Write ms | Writes/s | Join ms | Reopen ms | +| --- | ---: | ---: | ---: | ---: | ---: | +| local plaintext throttled visible snapshots | 250 | 100 | 2500 | 5 | 20 | +| local encrypted mutation log | 250 | 50 | 5000 | 4 | 10 | +| orm save() single transactions | 250 | 300 | 833.33 | 8 | 30 | +\`) + const current = parseBenchmarkEvidence(JSON.stringify({ + metadata: { + arch: 'arm64', + node: 'v-test', + platform: 'darwin', + repeatCount: 2, + warmupCount: 1, + }, + results: [ + { + joinMs: 4, + label: 'local plaintext throttled visible snapshots', + reopenMs: 16, + rows: 250, + writeMs: 90, + writesPerSecond: 3000, + }, + { + joinMs: 3, + label: 'local encrypted mutation log', + reopenMs: 9, + rows: 250, + writeMs: 45, + writesPerSecond: 5500, + }, + { + joinMs: 7, + label: 'orm save() single transactions', + reopenMs: 28, + rows: 250, + writeMs: 270, + writesPerSecond: 1000, + }, + ], + })) + const evidence = buildBenchmarkEvidence({ + baseline, + baselineLabel: 'previous', + baselineSource: 'docs/BENCHMARKS.md', + current: current.results, + currentLabel: 'current', + currentMetadata: current.metadata, + currentSource: '.gitdb/bench-current.json', + }) + const markdown = formatComparisonMarkdown(evidence) + console.log(JSON.stringify({ + comparisonCount: evidence.comparisons.length, + headline: evidence.headline, + metadataRepeatCount: evidence.current.metadata.repeatCount, + markdownIncludesOrm: markdown.includes('orm save() single transactions'), + runtimeIncludesNode: markdown.includes('Runtime: v-test on darwin/arm64; repeat=2, warmup=1'), + scenarioKeys: evidence.scenarios.map((scenario) => scenario.key), + })) + ` + const { stdout } = await execFileAsync(process.execPath, ["--input-type=module", "-e", code]) + return ProbeSchema.parse(JSON.parse(stdout)) +} diff --git a/tests/benchmark-script.test.ts b/tests/benchmark-script.test.ts new file mode 100644 index 0000000..4552431 --- /dev/null +++ b/tests/benchmark-script.test.ts @@ -0,0 +1,199 @@ +import { spawn } from "node:child_process" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, expect, it } from "vitest" +import packageJson from "../package.json" with { type: "json" } + +type ProcessResult = { + readonly code: number + readonly stderr: string + readonly stdout: string +} + +describe("benchmark evaluator", () => { + it("defines a benchmark evaluator script for JSON evidence output", () => { + // Given: package metadata is the user-facing command surface. + const scripts = packageJson.scripts + + // When: the evaluator command is inspected. + const command = scripts["benchmark:evaluate"] + + // Then: it builds first and asks the benchmark runner for JSON output. + expect(command).toBe("corepack pnpm build && node scripts/benchmark.mjs --json") + }) + + it("supports JSON output and evidence file targets in the benchmark runner", async () => { + // Given: the benchmark runner is the evaluator used by package scripts. + const script = await readFile("scripts/benchmark.mjs", "utf8") + + // When: the runner implementation is inspected. + const hasJsonFlag = script.includes("--json") + const hasOutputTarget = script.includes("GITDB_BENCH_OUTPUT") + + // Then: evaluator runs can produce machine-readable evidence. + expect(hasJsonFlag).toBe(true) + expect(hasOutputTarget).toBe(true) + }) + + it("defines a benchmark comparison command for the website evidence file", () => { + // Given: the public site should be generated from reproducible benchmark evidence. + const scripts = packageJson.scripts + + // When: package scripts are inspected. + const command = scripts["benchmark:compare"] + + // Then: the comparison command writes both machine and Markdown summaries. + expect(command).toContain("GITDB_BENCH_OUTPUT=.gitdb/bench-current.json") + expect(command).toContain("scripts/benchmark-compare.mjs") + expect(command).toContain("--baseline docs/BENCHMARK_BASELINE.json") + expect(command).toContain("--output site/benchmark.json") + expect(command).toContain("--markdown site/benchmark.md") + }) + + it("defines a benchmark gate command", async () => { + // Given: benchmark evidence must be CI-gated before release claims. + const scripts = packageJson.scripts + + // When: the package command surface is inspected. + const command = scripts["benchmark:gate"] + const gateScript = await readFile("scripts/benchmark-gate.mjs", "utf8") + + // Then: the gate builds first and runs a dedicated validator script. + expect(command).toBe("corepack pnpm build && node scripts/benchmark-gate.mjs") + expect(gateScript).toContain("--input") + expect(gateScript).toContain("missing scenario") + }) + + it("rejects benchmark evidence when required scenarios are missing", async () => { + // Given: malformed benchmark evidence with no scenario rows. + const root = await mkdtemp(join(tmpdir(), "gitdb-bench-gate-test-")) + try { + const input = join(root, "bad-benchmark.json") + await writeFile(input, '{"results":[]}\n', "utf8") + + // When: the gate validates the evidence file. + const result = await runNode(["scripts/benchmark-gate.mjs", "--input", input]) + + // Then: the failure names the missing scenario contract. + expect(result.code).toBe(1) + expect(`${result.stdout}${result.stderr}`).toContain("missing scenario") + } finally { + await rm(root, { force: true, recursive: true }) + } + }) + + it("accepts complete local benchmark evidence with repeat metadata", async () => { + // Given: a local benchmark result containing every current required scenario. + const root = await mkdtemp(join(tmpdir(), "gitdb-bench-gate-test-")) + try { + const input = join(root, "good-benchmark.json") + await writeFile(input, `${JSON.stringify(completeBenchmarkEvidence())}\n`, "utf8") + + // When: the gate validates the evidence file. + const result = await runNode(["scripts/benchmark-gate.mjs", "--input", input]) + + // Then: checked scenarios and repeat metadata are reported. + expect(result.code).toBe(0) + expect(result.stdout).toContain("repeat count: 3") + expect(result.stdout).toContain( + [ + "checked scenarios: local-plaintext", + "local-encrypted", + "orm-save-single", + "orm-insert", + "orm-insert-many", + "orm-save-many", + "orm-indexed-lookup", + "reopen", + "compaction", + ].join(", "), + ) + } finally { + await rm(root, { force: true, recursive: true }) + } + }) + + it("accepts website benchmark evidence with scenarios array", async () => { + // Given: the public website stores current benchmark rows under scenarios. + const root = await mkdtemp(join(tmpdir(), "gitdb-bench-gate-test-")) + try { + const input = join(root, "site-benchmark.json") + await writeFile( + input, + `${JSON.stringify({ + generatedAt: "2026-06-14T00:00:00.000Z", + scenarios: [ + benchmarkRow("local plaintext throttled visible snapshots"), + benchmarkRow("local encrypted mutation log"), + benchmarkRow("orm save() single transactions"), + benchmarkRow("orm insert() single transactions"), + benchmarkRow("orm insertMany bulk transaction"), + benchmarkRow("orm saveMany bulk transaction"), + benchmarkRow("orm indexed equality lookup"), + benchmarkRow("local plaintext reopen checkpoint"), + benchmarkRow("local plaintext compaction"), + ], + })}\n`, + "utf8", + ) + + // When: the gate validates website evidence. + const result = await runNode(["scripts/benchmark-gate.mjs", "--input", input]) + + // Then: site evidence can be reused by release checks. + expect(result.code).toBe(0) + expect(result.stdout).toContain("repeat count: 1") + } finally { + await rm(root, { force: true, recursive: true }) + } + }) +}) + +function completeBenchmarkEvidence() { + return { + metadata: { repeatCount: 3 }, + results: [ + benchmarkRow("local plaintext throttled visible snapshots"), + benchmarkRow("local encrypted mutation log"), + benchmarkRow("orm save() single transactions"), + benchmarkRow("orm insert() single transactions"), + benchmarkRow("orm insertMany bulk transaction"), + benchmarkRow("orm saveMany bulk transaction"), + benchmarkRow("orm indexed equality lookup"), + benchmarkRow("local plaintext reopen checkpoint"), + benchmarkRow("local plaintext compaction"), + ], + } +} + +function benchmarkRow(label: string) { + return { + joinMs: 5, + label, + reopenMs: 20, + rows: 250, + writeMs: 100, + writesPerSecond: 2500, + } +} + +function runNode(args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args) + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", (chunk: string) => { + stdout += chunk + }) + child.stderr.on("data", (chunk: string) => { + stderr += chunk + }) + child.on("error", reject) + child.on("close", (code) => { + resolve({ code: code ?? -1, stderr, stdout }) + }) + }) +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..c43fb24 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,157 @@ +import { Buffer } from "node:buffer" +import { execFile } from "node:child_process" +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { promisify } from "node:util" +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" +import { generateKey } from "../src/cli/keygen.js" + +const execFileAsync = promisify(execFile) +let cliBuildRoot = "" +let cliPath = "" + +type CliResult = { + readonly code: number + readonly stderr: string + readonly stdout: string +} + +describe("GitDB CLI contract", () => { + const roots: string[] = [] + + beforeAll(async () => { + const cacheRoot = join(process.cwd(), "node_modules", ".cache") + await mkdir(cacheRoot, { recursive: true }) + cliBuildRoot = await mkdtemp(join(cacheRoot, "gitdb-cli-dist-")) + await execFileAsync("corepack", [ + "pnpm", + "exec", + "tsc", + "-p", + "tsconfig.json", + "--outDir", + cliBuildRoot, + "--tsBuildInfoFile", + join(cliBuildRoot, "tsconfig.tsbuildinfo"), + ]) + cliPath = join(cliBuildRoot, "src", "cli", "main.js") + }, 30_000) + + afterAll(async () => { + if (cliBuildRoot.length > 0) { + await rm(cliBuildRoot, { force: true, recursive: true }) + } + }) + + afterEach(async () => { + // Given: CLI tests create isolated local store roots. + const pending = roots.splice(0, roots.length) + + // When: each test completes. + await Promise.all(pending.map((root) => rm(root, { force: true, recursive: true }))) + + // Then: no CLI state leaks into the next scenario. + expect(roots).toHaveLength(0) + }) + + it("generates base64url 32-byte encryption keys", () => { + // Given: users need a key suitable for AES-GCM storage. + const key = generateKey() + + // When: the key is decoded from the CLI-facing base64url representation. + const bytes = Buffer.from(key, "base64url") + + // Then: the key has exactly the required entropy size. + expect(bytes).toHaveLength(32) + expect(key).toMatch(/^[A-Za-z0-9_-]+$/) + }) + + it("runs built keygen check and query commands", async () => { + // Given: a built package binary and an isolated plaintext local root. + const root = await mkdtemp(join(tmpdir(), "gitdb-cli-built-")) + roots.push(root) + const env = { GITDB_ENCRYPTION: "off", GITDB_ROOT: root } + + // When: users run the installed-style CLI commands. + const keygen = await runBuiltCli(["keygen"], env) + const create = await runBuiltCli(["query", "CREATE TABLE people (id STRING, name STRING)"], env) + const insert = await runBuiltCli(["query", "INSERT INTO people VALUES ('p1', 'Lin')"], env) + const select = await runBuiltCli(["query", "SELECT * FROM people"], env) + const check = await runBuiltCli(["check"], env) + + // Then: commands exit cleanly and persisted query results are readable JSON. + expect(keygen.code).toBe(0) + expect(Buffer.from(keygen.stdout.trim(), "base64url")).toHaveLength(32) + expect(create.code).toBe(0) + expect(insert.code).toBe(0) + expect(JSON.parse(select.stdout)).toMatchObject({ + rows: [{ id: "p1", name: "Lin" }], + }) + expect(JSON.parse(check.stdout)).toMatchObject({ + mode: "local-plaintext", + status: "ok", + }) + }, 15_000) + + it("prints human-readable CLI errors with non-zero exit status", async () => { + // Given: encrypted mode is requested without the required key. + const root = await mkdtemp(join(tmpdir(), "gitdb-cli-missing-key-")) + roots.push(root) + + // When: the built CLI tries to open the configured store. + const result = await runBuiltCli(["check"], { + GITDB_ENCRYPTION: "on", + GITDB_ROOT: root, + }) + + // Then: the process fails clearly without dumping a stack trace. + expect(result.code).toBe(1) + expect(result.stderr).toContain("ConfigError: GITDB_KEY is required") + expect(result.stderr).not.toContain(" at ") + }) + + it("exposes keygen query and check commands from the CLI entrypoint", async () => { + // Given: the published binary points at the CLI entrypoint. + const source = await readFile("src/cli/main.ts", "utf8") + + // When: the command surface is inspected. + const commands = ["keygen", "query", "check"] as const + + // Then: the expected first-party runtime commands remain present. + for (const command of commands) { + expect(source).toContain(`.command("${command}")`) + } + expect(source).toContain("createStoreFromEnv(process.env)") + }) +}) + +async function runBuiltCli( + args: readonly string[], + env: Readonly>, +): Promise { + return await new Promise((resolve) => { + execFile( + process.execPath, + [cliPath, ...args], + { env: { ...process.env, LOG_LEVEL: "silent", ...env } }, + (error, stdout, stderr) => { + resolve({ + code: exitCode(error), + stderr: stderr.toString(), + stdout: stdout.toString(), + }) + }, + ) + }) +} + +function exitCode(error: Error | null): number { + if (error === null) { + return 0 + } + if ("code" in error && typeof error.code === "number") { + return error.code + } + return 1 +} diff --git a/tests/compaction.test.ts b/tests/compaction.test.ts new file mode 100644 index 0000000..4ba1bf5 --- /dev/null +++ b/tests/compaction.test.ts @@ -0,0 +1,104 @@ +import { mkdtemp, readdir } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, expect, it } from "vitest" +import { GitDbEngine } from "../src/sql/engine.js" +import { LocalPlaintextStore } from "../src/storage/local-plaintext-store.js" +import type { GitDbManifest, VisibleDatabaseSnapshot } from "../src/types.js" + +describe("log compaction", () => { + it("does not lose committed rows across compaction crash points", async () => { + // Given: a committed plaintext database with replayable mutation segments. + const root = await mkdtemp(join(tmpdir(), "gitdb-compaction-crash-")) + await writePeople(root, 6) + const store = new LocalPlaintextStore({ root }) + const manifest = await requiredManifest(store) + expect(manifest.logSegments.length).toBeGreaterThan(0) + + // When/Then: a crash before checkpoint leaves the old manifest/log usable. + await expect(selectPeople(root)).resolves.toHaveLength(6) + + // When/Then: a crash after checkpoint but before manifest update is still usable. + await store.writeVisibleSnapshot(peopleSnapshot(manifest.sequence, 6)) + await expect(selectPeople(root)).resolves.toHaveLength(6) + + // When/Then: a crash after manifest advance but before log deletion reopens from checkpoint. + await store.writeManifest({ ...manifest, logSegments: [] }) + expect(await logFileNames(root)).toHaveLength(manifest.logSegments.length) + await expect(selectPeople(root)).resolves.toHaveLength(6) + + // When/Then: completed compaction preserves rows and removes obsolete logs. + const compactRoot = await mkdtemp(join(tmpdir(), "gitdb-compaction-complete-")) + const engine = await writePeople(compactRoot, 6) + const result = await engine.compact() + expect(result.deletedSegments.length).toBeGreaterThan(0) + await expect(logFileNames(compactRoot)).resolves.toHaveLength(0) + await expect(selectPeople(compactRoot)).resolves.toHaveLength(6) + }) + + it("reopens with fewer log segments after compaction", async () => { + // Given: a database whose manifest points at multiple mutation segments. + const root = await mkdtemp(join(tmpdir(), "gitdb-compaction-reopen-")) + const engine = await writePeople(root, 10) + const before = await requiredManifest(new LocalPlaintextStore({ root })) + + // When: committed logs are compacted behind a visible checkpoint. + const result = await engine.compact() + const after = await requiredManifest(new LocalPlaintextStore({ root })) + + // Then: manifest replay work drops while rows survive reopen. + expect(before.logSegments.length).toBeGreaterThan(after.logSegments.length) + expect(after.logSegments).toHaveLength(0) + expect(result.manifest.logSegments).toHaveLength(0) + await expect(selectPeople(root)).resolves.toHaveLength(10) + }) +}) + +async function writePeople(root: string, count: number): Promise { + const engine = await GitDbEngine.open({ store: new LocalPlaintextStore({ root }) }) + await engine.execute("CREATE TABLE people (id STRING, name STRING)") + for (let index = 0; index < count; index += 1) { + await engine.execute(`INSERT INTO people VALUES ('p${index}', 'Person ${index}')`) + } + return engine +} + +async function selectPeople(root: string): Promise { + const engine = await GitDbEngine.open({ store: new LocalPlaintextStore({ root }) }) + return (await engine.execute("SELECT * FROM people")).rows +} + +async function requiredManifest(store: LocalPlaintextStore): Promise { + const manifest = await store.readManifest() + if (manifest === null) { + throw new TypeError("expected manifest") + } + return manifest +} + +async function logFileNames(root: string): Promise { + try { + return await readdir(join(root, "gitdb/v1/log")) + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return [] + } + throw error + } +} + +function peopleSnapshot(sequence: number, count: number): VisibleDatabaseSnapshot { + return { + sequence, + tables: [ + { + columns: ["id", "name"], + name: "people", + rows: Array.from({ length: count }, (_, index) => ({ + id: `p${index}`, + name: `Person ${index}`, + })), + }, + ], + } +} diff --git a/tests/config-env.test.ts b/tests/config-env.test.ts new file mode 100644 index 0000000..52b9760 --- /dev/null +++ b/tests/config-env.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest" +import { createStoreFromEnv } from "../src/cli/store-factory.js" +import { parseEnv } from "../src/config/env.js" +import { parseGitHubEnv } from "../src/config/github-env.js" +import { ConfigError } from "../src/errors.js" + +describe("GitDB environment parsing", () => { + it("allows plaintext local mode without an encryption key", () => { + // Given: a local demo environment disables encrypted storage. + const env = { + GITDB_ENCRYPTION: "off", + GITDB_ROOT: "/tmp/gitdb", + } satisfies NodeJS.ProcessEnv + + // When: the base GitDB environment is parsed. + const parsed = parseEnv(env) + + // Then: plaintext mode is accepted and keeps the configured root. + expect(parsed).toEqual({ + GITDB_ENCRYPTION: "off", + GITDB_ROOT: "/tmp/gitdb", + }) + }) + + it("requires a key when encrypted mode is enabled", () => { + // Given: encrypted mode is the default when no override is provided. + const env = {} satisfies NodeJS.ProcessEnv + + // When/Then: parsing fails before a store can be created with an invalid key. + expect(() => parseEnv(env)).toThrow(ConfigError) + expect(() => parseEnv(env)).toThrow("GITDB_KEY is required") + }) + + it("returns null for incomplete GitHub configuration", () => { + // Given: local-only mode leaves token blank even if owner and repo are present. + const env = { + GITDB_GITHUB_OWNER: "3x-haust", + GITDB_GITHUB_REPO: "gitdb-data", + GITDB_GITHUB_TOKEN: " ", + } satisfies NodeJS.ProcessEnv + + // When: GitHub-specific config is parsed. + const parsed = parseGitHubEnv(env) + + // Then: GitDB stays on the local store path. + expect(parsed).toBeNull() + }) + + it("applies GitHub branch and prefix defaults when remote config is complete", () => { + // Given: the minimum GitHub remote environment is present. + const env = { + GITDB_GITHUB_OWNER: "3x-haust", + GITDB_GITHUB_REPO: "gitdb-data", + GITDB_GITHUB_TOKEN: "token", + } satisfies NodeJS.ProcessEnv + + // When: GitHub-specific config is parsed. + const parsed = parseGitHubEnv(env) + + // Then: the durable remote namespace has stable defaults. + expect(parsed).toEqual({ + branch: "main", + owner: "3x-haust", + prefix: "gitdb/v1", + repo: "gitdb-data", + token: "token", + }) + }) + + it("selects local plaintext store mode from environment", () => { + // Given: no GitHub credentials and encryption disabled. + const env = { + GITDB_ENCRYPTION: "off", + GITDB_ROOT: "/tmp/gitdb", + } satisfies NodeJS.ProcessEnv + + // When: the CLI store factory is evaluated. + const result = createStoreFromEnv(env) + + // Then: the CLI will use readable local files. + expect(result.mode).toBe("local-plaintext") + }) +}) diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 4d3b58e..877f89f 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -1,51 +1,104 @@ import { mkdtemp } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { Client } from "pg" -import { afterEach, describe, expect, it } from "vitest" +import { describe, expect, it } from "vitest" import { createAesGcmCipher } from "../src/crypto/aes-gcm.js" -import { createGitDbServer } from "../src/protocol/postgres-server.js" -import { GitDbEngine } from "../src/sql/engine.js" +import { createGitDbDataSource, defineEntity } from "../src/orm/index.js" import { LocalEncryptedStore } from "../src/storage/local-encrypted-store.js" -describe("PostgreSQL facade", () => { - const servers: { readonly close: () => Promise }[] = [] +type Team = { + readonly id: string + readonly name: string +} - afterEach(async () => { - // Given: test servers may have been started by a scenario. - const pending = servers.splice(0, servers.length) +type Person = { + readonly id: string + readonly name: string + readonly team_id: string +} - // When: the scenario ends. - await Promise.all(pending.map((server) => server.close())) +const TeamEntity = defineEntity({ + columns: { + id: "STRING", + name: "STRING", + }, + primaryKey: "id", + tableName: "teams", +}) - // Then: no server leaks into the next E2E run. - expect(servers).toHaveLength(0) - }) +const PersonEntity = defineEntity({ + columns: { + id: "STRING", + name: "STRING", + team_id: "STRING", + }, + primaryKey: "id", + tableName: "people", +}) - it("accepts a pg client and executes join queries through postgres://", async () => { - // Given: a GitDB PostgreSQL-compatible server backed by encrypted GitDB files. - const root = await mkdtemp(join(tmpdir(), "gitdb-pg-")) +describe("GitDB first-party runtime", () => { + it("writes through repositories, joins through the engine, and reopens encrypted state", async () => { + // Given: a GitDB data source backed by encrypted local storage. + const root = await mkdtemp(join(tmpdir(), "gitdb-runtime-")) const key = Buffer.alloc(32, 3).toString("base64url") - const store = new LocalEncryptedStore({ root, cipher: createAesGcmCipher(key) }) - const engine = await GitDbEngine.open({ store }) - const server = await createGitDbServer({ engine, host: "127.0.0.1", port: 0 }) - servers.push(server) - const client = new Client({ - connectionString: `postgresql://127.0.0.1:${server.port}/main`, + const cipher = createAesGcmCipher(key) + const entities = [TeamEntity, PersonEntity] + const dataSource = await createGitDbDataSource({ + entities, + store: new LocalEncryptedStore({ cipher, root }), + synchronize: true, }) - // When: an ordinary Postgres client drives schema, writes, and a relation join. - await client.connect() - await client.query("CREATE TABLE teams (id STRING, name STRING)") - await client.query("CREATE TABLE people (id STRING, name STRING, team_id STRING)") - await client.query("INSERT INTO teams VALUES ('t1', 'Storage')") - await client.query("INSERT INTO people VALUES ('p1', 'Lin', 't1')") - const result = await client.query( + // When: an app uses first-party repositories and SQL on the same runtime. + await dataSource.getRepository(TeamEntity).save({ id: "t1", name: "Storage" }) + await dataSource.getRepository(PersonEntity).save({ id: "p1", name: "Lin", team_id: "t1" }) + const joined = await dataSource.query( "SELECT people.name AS person, teams.name AS team FROM people JOIN teams ON people.team_id = teams.id", ) - await client.end() + const reopened = await createGitDbDataSource({ + entities, + store: new LocalEncryptedStore({ cipher: createAesGcmCipher(key), root }), + synchronize: false, + }) + + // Then: the runtime behaves as one database across repository, query, and reopen paths. + expect(joined).toEqual([{ person: "Lin", team: "Storage" }]) + await expect(reopened.getRepository(PersonEntity).find()).resolves.toEqual([ + { id: "p1", name: "Lin", team_id: "t1" }, + ]) + }) + + it("bulk repositories survive encrypted reopen", async () => { + // Given: encrypted local storage and synchronized repositories. + const root = await mkdtemp(join(tmpdir(), "gitdb-runtime-bulk-")) + const key = Buffer.alloc(32, 17).toString("base64url") + const cipher = createAesGcmCipher(key) + const entities = [TeamEntity, PersonEntity] + const dataSource = await createGitDbDataSource({ + entities, + store: new LocalEncryptedStore({ cipher, root }), + synchronize: true, + }) + + // When: repositories use bulk APIs and the database reopens. + await dataSource.getRepository(TeamEntity).insertMany([ + { id: "t1", name: "Storage" }, + { id: "t2", name: "Runtime" }, + ]) + await dataSource.getRepository(PersonEntity).saveMany([ + { id: "p1", name: "Lin", team_id: "t1" }, + { id: "p2", name: "Ada", team_id: "t2" }, + ]) + const reopened = await createGitDbDataSource({ + entities, + store: new LocalEncryptedStore({ cipher: createAesGcmCipher(key), root }), + synchronize: false, + }) - // Then: the ORM-facing surface behaves like a PostgreSQL connection. - expect(result.rows).toEqual([{ person: "Lin", team: "Storage" }]) + // Then: bulk-written rows survive encrypted replay. + await expect(reopened.getRepository(PersonEntity).find()).resolves.toEqual([ + { id: "p1", name: "Lin", team_id: "t1" }, + { id: "p2", name: "Ada", team_id: "t2" }, + ]) }) }) diff --git a/tests/github-encrypted-store.test.ts b/tests/github-encrypted-store.test.ts new file mode 100644 index 0000000..18ebd29 --- /dev/null +++ b/tests/github-encrypted-store.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { createAesGcmCipher } from "../src/crypto/aes-gcm.js" +import { GitHubEncryptedStore } from "../src/github/github-encrypted-store.js" +import type { GitHubConfig } from "../src/github/types.js" +import type { GitDbManifest, PersistedMutation } from "../src/types.js" +import { segmentId } from "../src/types.js" + +const octokit = vi.hoisted(() => ({ + createCommit: vi.fn(), + createOrUpdateFileContents: vi.fn(), + createTree: vi.fn(), + getCommit: vi.fn(), + getContent: vi.fn(), + getRef: vi.fn(), + updateRef: vi.fn(), +})) + +vi.mock("@octokit/rest", () => ({ + Octokit: vi.fn(function MockOctokit() { + return { + git: { + createCommit: octokit.createCommit, + createTree: octokit.createTree, + getCommit: octokit.getCommit, + getRef: octokit.getRef, + updateRef: octokit.updateRef, + }, + repos: { + createOrUpdateFileContents: octokit.createOrUpdateFileContents, + getContent: octokit.getContent, + }, + } + }), +})) + +const config = { + branch: "main", + owner: "3x-haust", + prefix: "gitdb/v1", + repo: "gitdb-example-db", + token: "redacted-token", +} satisfies GitHubConfig + +const key = Buffer.alloc(32, 17).toString("base64url") + +describe("GitHubEncryptedStore", () => { + beforeEach(() => { + octokit.createCommit.mockReset() + octokit.createOrUpdateFileContents.mockReset() + octokit.createTree.mockReset() + octokit.getCommit.mockReset() + octokit.getContent.mockReset() + octokit.getRef.mockReset() + octokit.updateRef.mockReset() + mockResolvedGitPath() + }) + + it("reads encrypted manifest and mutation files from GitHub contents", async () => { + // Given: GitHub returns encrypted payload files. + const cipher = createAesGcmCipher(key) + const mutation: PersistedMutation = { + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "INSERT INTO people VALUES ('p1', 'Lin')", + } + const manifest: GitDbManifest = { + createdAt: mutation.at, + logSegments: [segmentId("00000000000000000001")], + sequence: 1, + updatedAt: mutation.at, + version: 1, + } + octokit.getContent.mockImplementation(async ({ path }: { readonly path: string }) => { + if (path === "gitdb/v1/manifest.enc") { + return { data: encodedEncryptedFile(cipher, manifest, "manifest-sha") } + } + if (path === "gitdb/v1/log/00000000000000000001.enc") { + return { data: encodedEncryptedFile(cipher, mutation, "segment-sha") } + } + throw new FakeGitHubStatusError(404) + }) + const store = new GitHubEncryptedStore(config, cipher) + + // When: the store opens remote encrypted state. + const parsedManifest = await store.readManifest() + const parsedMutations = await store.readMutations([segmentId("00000000000000000001")]) + + // Then: decrypted data matches the durable GitDB state. + expect(parsedManifest).toEqual(manifest) + expect(parsedMutations).toEqual([mutation]) + }) + + it("reads encrypted v2 manifest metadata from GitHub contents", async () => { + // Given: GitHub returns an encrypted v2 manifest payload. + const cipher = createAesGcmCipher(key) + const manifest: GitDbManifest = { + createdAt: "2026-06-14T00:00:00.000Z", + logSegments: [], + metadata: { migratedFrom: 1, storageEngine: "gitdb" }, + sequence: 0, + updatedAt: "2026-06-14T00:00:00.000Z", + version: 2, + } + octokit.getContent.mockImplementation(async ({ path }: { readonly path: string }) => { + if (path === "gitdb/v1/manifest.enc") { + return { data: encodedEncryptedFile(cipher, manifest, "manifest-sha") } + } + throw new FakeGitHubStatusError(404) + }) + const store = new GitHubEncryptedStore(config, cipher) + + // When: the encrypted store reads the manifest. + const parsedManifest = await store.readManifest() + + // Then: v2 metadata survives the encrypted boundary. + expect(parsedManifest).toEqual(manifest) + }) + + it("writes encrypted contents without exposing table names or SQL text", async () => { + // Given: GitHub has a branch that can accept an encrypted tree commit. + const cipher = createAesGcmCipher(key) + const store = new GitHubEncryptedStore(config, cipher) + + // When: a mutation is appended to GitHub. + const segment = await store.appendMutation({ + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "CREATE TABLE people (id STRING)", + }) + + // Then: the GitHub tree entry content is an encrypted sealed string. + const entry = treeEntries()[0] + expect(segment).toBe("00000000000000000001") + expect(entry?.path).toBe("gitdb/v1/log/00000000000000000001.enc") + expect(entry?.content).not.toContain("CREATE TABLE") + expect(entry?.content).not.toContain("people") + expect(octokit.createOrUpdateFileContents).not.toHaveBeenCalled() + }) +}) + +class FakeGitHubStatusError extends Error { + constructor(readonly status: number) { + super(`GitHub status ${status}`) + } +} + +function encodedEncryptedFile( + cipher: ReturnType, + value: unknown, + sha: string, +): { readonly content: string; readonly sha: string } { + const sealed = cipher.seal(Buffer.from(JSON.stringify(value), "utf8")) + return { + content: Buffer.from(sealed, "utf8").toString("base64"), + sha, + } +} + +function mockResolvedGitPath(): void { + octokit.getRef.mockResolvedValue({ data: { object: { sha: "base-commit-sha" } } }) + octokit.getCommit.mockResolvedValue({ data: { tree: { sha: "base-tree-sha" } } }) + octokit.createTree.mockResolvedValue({ data: { sha: "new-tree-sha" } }) + octokit.createCommit.mockResolvedValue({ data: { sha: "new-commit-sha" } }) + octokit.updateRef.mockResolvedValue({ data: {} }) +} + +function treeEntries(): readonly { readonly content: string; readonly path: string }[] { + const request = octokit.createTree.mock.calls[0]?.[0] + if (request === undefined || !Array.isArray(request.tree)) { + throw new TypeError("expected createTree request with tree entries") + } + return request.tree.map((entry: { readonly content: string; readonly path: string }) => ({ + content: entry.content, + path: entry.path, + })) +} diff --git a/tests/github-git-database-client.test.ts b/tests/github-git-database-client.test.ts new file mode 100644 index 0000000..07d3e0a --- /dev/null +++ b/tests/github-git-database-client.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { + GitHubGitDatabaseClient, + GitHubGitDatabaseConflictError, + GitHubGitDatabaseRateLimitError, + GitHubGitDatabaseTransientError, +} from "../src/github/git-database-client.js" +import type { GitHubConfig } from "../src/github/types.js" + +const octokit = vi.hoisted(() => ({ + createCommit: vi.fn(), + createTree: vi.fn(), + getCommit: vi.fn(), + getRef: vi.fn(), + updateRef: vi.fn(), +})) + +vi.mock("@octokit/rest", () => ({ + Octokit: vi.fn(function MockOctokit() { + return { + git: { + createCommit: octokit.createCommit, + createTree: octokit.createTree, + getCommit: octokit.getCommit, + getRef: octokit.getRef, + updateRef: octokit.updateRef, + }, + } + }), +})) + +const config = { + branch: "main", + owner: "3x-haust", + prefix: "gitdb/v1", + repo: "gitdb-example-db", + token: "redacted-token", +} satisfies GitHubConfig + +describe("GitHub Git Database client", () => { + beforeEach(() => { + octokit.createCommit.mockReset() + octokit.createTree.mockReset() + octokit.getCommit.mockReset() + octokit.getRef.mockReset() + octokit.updateRef.mockReset() + }) + + it("updates a branch with one non-force tree commit", async () => { + // Given: the target branch points to an existing commit and tree. + octokit.getRef.mockResolvedValue({ data: { object: { sha: "base-commit-sha" } } }) + octokit.getCommit.mockResolvedValue({ data: { tree: { sha: "base-tree-sha" } } }) + octokit.createTree.mockResolvedValue({ data: { sha: "new-tree-sha" } }) + octokit.createCommit.mockResolvedValue({ data: { sha: "new-commit-sha" } }) + octokit.updateRef.mockResolvedValue({ data: {} }) + const client = new GitHubGitDatabaseClient(config) + + // When: GitDB writes one file through a Git Database tree commit. + const result = await client.commitTree({ + entries: [{ content: "{}", path: "gitdb/v1/manifest.json" }], + message: "gitdb sync batch", + }) + + // Then: the branch advances with an optimistic non-force ref update. + expect(result).toEqual({ + baseCommitSha: "base-commit-sha", + commitSha: "new-commit-sha", + treeSha: "new-tree-sha", + }) + expect(octokit.getRef).toHaveBeenCalledWith({ + owner: "3x-haust", + ref: "heads/main", + repo: "gitdb-example-db", + }) + expect(octokit.createTree).toHaveBeenCalledWith({ + base_tree: "base-tree-sha", + owner: "3x-haust", + repo: "gitdb-example-db", + tree: [{ content: "{}", mode: "100644", path: "gitdb/v1/manifest.json", type: "blob" }], + }) + expect(octokit.createCommit).toHaveBeenCalledWith({ + message: "gitdb sync batch", + owner: "3x-haust", + parents: ["base-commit-sha"], + repo: "gitdb-example-db", + tree: "new-tree-sha", + }) + expect(octokit.updateRef).toHaveBeenCalledWith({ + force: false, + owner: "3x-haust", + ref: "heads/main", + repo: "gitdb-example-db", + sha: "new-commit-sha", + }) + }) + + it("classifies GitHub 409 ref failures as write conflicts", async () => { + // Given: GitHub rejects the ref update because the branch moved. + octokit.getRef.mockResolvedValue({ data: { object: { sha: "base-commit-sha" } } }) + octokit.getCommit.mockResolvedValue({ data: { tree: { sha: "base-tree-sha" } } }) + octokit.createTree.mockResolvedValue({ data: { sha: "new-tree-sha" } }) + octokit.createCommit.mockResolvedValue({ data: { sha: "new-commit-sha" } }) + octokit.updateRef.mockRejectedValue(new FakeGitHubStatusError(409)) + const client = new GitHubGitDatabaseClient(config) + + // When/Then: GitDB exposes a typed conflict for retry/replay callers. + await expect( + client.commitTree({ + entries: [{ content: "{}", path: "gitdb/v1/manifest.json" }], + message: "gitdb sync batch", + }), + ).rejects.toBeInstanceOf(GitHubGitDatabaseConflictError) + }) + + it("classifies temporary GitHub failures as transient", async () => { + // Given: GitHub returns a temporary service failure while reading the ref. + octokit.getRef.mockRejectedValue(new FakeGitHubStatusError(503)) + const client = new GitHubGitDatabaseClient(config) + + // When/Then: GitDB exposes a typed transient error. + await expect( + client.commitTree({ + entries: [{ content: "{}", path: "gitdb/v1/manifest.json" }], + message: "gitdb sync batch", + }), + ).rejects.toBeInstanceOf(GitHubGitDatabaseTransientError) + }) + + it("includes retry context for GitHub rate limits", async () => { + // Given: GitHub says the token is rate limited and provides retry timing. + octokit.getRef.mockRejectedValue(new FakeGitHubRateLimitError(7)) + const client = new GitHubGitDatabaseClient(config) + + // When: GitDB writes a commit while GitHub is rate limited. + const result = client.commitTree({ + entries: [{ content: "{}", path: "gitdb/v1/manifest.json" }], + message: "gitdb sync batch", + }) + + // Then: the typed error preserves retry context for the scheduler. + await expect(result).rejects.toBeInstanceOf(GitHubGitDatabaseRateLimitError) + await expect(result).rejects.toMatchObject({ retryAfterSeconds: 7 }) + }) +}) + +class FakeGitHubStatusError extends Error { + constructor(readonly status: number) { + super(`GitHub status ${status}`) + } +} + +class FakeGitHubRateLimitError extends Error { + readonly response: { + readonly headers: { + readonly "retry-after": string + readonly "x-ratelimit-remaining": string + } + } + readonly status = 403 + + constructor(retryAfterSeconds: number) { + super("GitHub rate limit") + this.response = { + headers: { + "retry-after": retryAfterSeconds.toString(), + "x-ratelimit-remaining": "0", + }, + } + } +} diff --git a/tests/github-plaintext-store.test.ts b/tests/github-plaintext-store.test.ts new file mode 100644 index 0000000..2c02878 --- /dev/null +++ b/tests/github-plaintext-store.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { GitHubPlaintextStore } from "../src/github/github-plaintext-store.js" +import type { GitHubConfig } from "../src/github/types.js" + +const octokit = vi.hoisted(() => ({ + createCommit: vi.fn(), + createOrUpdateFileContents: vi.fn(), + createTree: vi.fn(), + getCommit: vi.fn(), + getContent: vi.fn(), + getRef: vi.fn(), + updateRef: vi.fn(), +})) + +vi.mock("@octokit/rest", () => ({ + Octokit: vi.fn(function MockOctokit() { + return { + git: { + createCommit: octokit.createCommit, + createTree: octokit.createTree, + getCommit: octokit.getCommit, + getRef: octokit.getRef, + updateRef: octokit.updateRef, + }, + repos: { + createOrUpdateFileContents: octokit.createOrUpdateFileContents, + getContent: octokit.getContent, + }, + } + }), +})) + +const config = { + branch: "main", + owner: "3x-haust", + prefix: "gitdb/v1", + repo: "gitdb-example-db", + token: "redacted-token", +} satisfies GitHubConfig + +describe("GitHubPlaintextStore", () => { + beforeEach(() => { + octokit.createCommit.mockReset() + octokit.createOrUpdateFileContents.mockReset() + octokit.createTree.mockReset() + octokit.getCommit.mockReset() + octokit.getContent.mockReset() + octokit.getRef.mockReset() + octokit.updateRef.mockReset() + mockResolvedGitPath() + }) + + it("reads visible snapshot checkpoint metadata", async () => { + // Given: GitHub stores a visible table snapshot with checkpoint metadata. + octokit.getContent.mockImplementation(async ({ path }: { readonly path: string }) => { + const content = githubContentFor(path) + if (content === null) { + throw new FakeGitHubStatusError(404) + } + return { data: content } + }) + const store = new GitHubPlaintextStore(config) + + // When: the visible snapshot is read. + const snapshot = await store.readVisibleSnapshot() + + // Then: the checkpoint sequence is preserved for manifest freshness checks. + expect(snapshot).toEqual({ + sequence: 7, + tables: [ + { + columns: ["id", "name"], + name: "people", + rows: [{ id: "p1", name: "Lin" }], + }, + ], + }) + }) + + it("writes visible snapshot checkpoint metadata", async () => { + // Given: GitHub has a branch that can accept a snapshot tree commit. + const store = new GitHubPlaintextStore(config) + + // When: a checkpointed visible snapshot is written. + await store.writeVisibleSnapshot({ + sequence: 9, + tables: [ + { + columns: ["id", "name"], + name: "people", + rows: [{ id: "p1", name: "Lin" }], + }, + ], + }) + + // Then: GitHub receives snapshot metadata with table dashboard files in one tree. + const entries = treeEntries() + expect(entries.map((entry) => entry.path)).toEqual([ + "gitdb/v1/people/schema.json", + "gitdb/v1/people/pages/000000.json", + "gitdb/v1/people/pages.json", + "gitdb/v1/people/indexes.json", + "gitdb/v1/snapshot.json", + ]) + expect(JSON.parse(entries[4]?.content ?? "null")).toEqual({ sequence: 9 }) + expect(octokit.createOrUpdateFileContents).not.toHaveBeenCalled() + }) +}) + +class FakeGitHubStatusError extends Error { + constructor(readonly status: number) { + super(`GitHub status ${status}`) + } +} + +function githubContentFor(path: string): unknown { + switch (path) { + case "gitdb/v1": + return [ + { name: "log", type: "dir" }, + { name: "people", type: "dir" }, + ] + case "gitdb/v1/snapshot.json": + return encodedFile({ sequence: 7 }, "snapshot-sha") + case "gitdb/v1/people/schema.json": + return encodedFile({ columns: ["id", "name"], name: "people" }, "schema-sha") + case "gitdb/v1/people/data.json": + return encodedFile([{ id: "p1", name: "Lin" }], "data-sha") + default: + return null + } +} + +function encodedFile( + value: unknown, + sha: string, +): { readonly content: string; readonly sha: string } { + return { + content: Buffer.from(JSON.stringify(value), "utf8").toString("base64"), + sha, + } +} + +function mockResolvedGitPath(): void { + octokit.getRef.mockResolvedValue({ data: { object: { sha: "base-commit-sha" } } }) + octokit.getCommit.mockResolvedValue({ data: { tree: { sha: "base-tree-sha" } } }) + octokit.createTree.mockResolvedValue({ data: { sha: "new-tree-sha" } }) + octokit.createCommit.mockResolvedValue({ data: { sha: "new-commit-sha" } }) + octokit.updateRef.mockResolvedValue({ data: {} }) +} + +function treeEntries(): readonly { readonly content: string; readonly path: string }[] { + const request = octokit.createTree.mock.calls[0]?.[0] + if (request === undefined || !Array.isArray(request.tree)) { + throw new TypeError("expected createTree request with tree entries") + } + return request.tree.map((entry: { readonly content: string; readonly path: string }) => ({ + content: entry.content, + path: entry.path, + })) +} diff --git a/tests/github-tree-store.test.ts b/tests/github-tree-store.test.ts new file mode 100644 index 0000000..bbc4ab8 --- /dev/null +++ b/tests/github-tree-store.test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { z } from "zod" +import { createAesGcmCipher } from "../src/crypto/aes-gcm.js" +import { GitHubEncryptedStore } from "../src/github/github-encrypted-store.js" +import { GitHubPlaintextStore } from "../src/github/github-plaintext-store.js" +import type { GitHubConfig } from "../src/github/types.js" +import type { GitDbCommitBatch } from "../src/storage/store.js" +import type { GitDbManifest, PersistedMutation } from "../src/types.js" +import { segmentId } from "../src/types.js" + +const octokit = vi.hoisted(() => ({ + createCommit: vi.fn(), + createOrUpdateFileContents: vi.fn(), + createTree: vi.fn(), + getCommit: vi.fn(), + getContent: vi.fn(), + getRef: vi.fn(), + updateRef: vi.fn(), +})) + +vi.mock("@octokit/rest", () => ({ + Octokit: vi.fn(function MockOctokit() { + return { + git: { + createCommit: octokit.createCommit, + createTree: octokit.createTree, + getCommit: octokit.getCommit, + getRef: octokit.getRef, + updateRef: octokit.updateRef, + }, + repos: { + createOrUpdateFileContents: octokit.createOrUpdateFileContents, + getContent: octokit.getContent, + }, + } + }), +})) + +const config = { + branch: "main", + owner: "3x-haust", + prefix: "gitdb/v1", + repo: "gitdb-example-db", + token: "redacted-token", +} satisfies GitHubConfig + +const TreeCreateRequestSchema = z.object({ + tree: z.array( + z.object({ + content: z.string(), + path: z.string(), + }), + ), +}) + +const createTable: PersistedMutation = { + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "CREATE TABLE people (id STRING, name STRING)", +} + +const insertRow: PersistedMutation = { + at: "2026-06-14T00:00:01.000Z", + sequence: 2, + sql: "INSERT INTO people VALUES ('p1', 'Lin')", +} + +const manifest: GitDbManifest = { + createdAt: createTable.at, + logSegments: [segmentId("00000000000000000001"), segmentId("00000000000000000002")], + sequence: 2, + updatedAt: insertRow.at, + version: 1, +} + +describe("GitHub tree commit stores", () => { + beforeEach(() => { + octokit.createCommit.mockReset() + octokit.createOrUpdateFileContents.mockReset() + octokit.createTree.mockReset() + octokit.getCommit.mockReset() + octokit.getContent.mockReset() + octokit.getRef.mockReset() + octokit.updateRef.mockReset() + mockGitDatabaseBase() + }) + + it("commits manifest log and snapshot files in one tree commit", async () => { + // Given: a plaintext GitHub store receives a two-mutation engine batch. + const store = new GitHubPlaintextStore(config) + + // When: the store commits the durable batch. + const result = await store.commitBatch(batch()) + + // Then: GitHub receives one tree commit containing the manifest and log files. + expect(result).toEqual({ + manifest, + segments: manifest.logSegments, + }) + expect(octokit.createOrUpdateFileContents).not.toHaveBeenCalled() + expect(octokit.createTree).toHaveBeenCalledTimes(1) + expect(treePaths()).toEqual([ + "gitdb/v1/log/00000000000000000001.json", + "gitdb/v1/log/00000000000000000002.json", + "gitdb/v1/manifest.json", + ]) + expect(octokit.updateRef).toHaveBeenCalledWith({ + force: false, + owner: "3x-haust", + ref: "heads/main", + repo: "gitdb-example-db", + sha: "new-commit-sha", + }) + }) + + it("retries branch conflicts with a fresh base ref and without force", async () => { + // Given: GitHub rejects the first branch update because the branch moved. + octokit.updateRef + .mockRejectedValueOnce(new FakeGitHubStatusError(409)) + .mockResolvedValueOnce({ data: {} }) + const store = new GitHubPlaintextStore(config) + + // When: the store commits the durable batch. + await store.commitBatch(batch()) + + // Then: the retry rereads the base ref and still uses non-force updates. + expect(octokit.getRef).toHaveBeenCalledTimes(2) + expect(octokit.createTree).toHaveBeenCalledTimes(2) + expect(octokit.updateRef.mock.calls.map(([request]) => request.force)).toEqual([false, false]) + }) + + it("writes encrypted batches without exposing SQL text", async () => { + // Given: an encrypted GitHub store receives SQL mutations. + const store = new GitHubEncryptedStore( + config, + createAesGcmCipher(Buffer.alloc(32, 31).toString("base64url")), + ) + + // When: the encrypted store commits the durable batch. + const result = await store.commitBatch(batch()) + + // Then: tree entries use encrypted files and opaque sealed content. + expect(result.segments).toEqual(manifest.logSegments) + expect(treePaths()).toEqual([ + "gitdb/v1/log/00000000000000000001.enc", + "gitdb/v1/log/00000000000000000002.enc", + "gitdb/v1/manifest.enc", + ]) + for (const content of treeContents()) { + expect(content).not.toContain("CREATE TABLE") + expect(content).not.toContain("INSERT INTO") + expect(content).not.toContain("people") + } + }) + + it("rejects stale manifest compare-and-swap before creating a tree", async () => { + // Given: the remote manifest has advanced beyond the caller's expected sequence. + octokit.getContent.mockResolvedValue({ + data: encodedFile({ ...manifest, sequence: 3 }, "manifest-sha"), + }) + const store = new GitHubPlaintextStore(config) + + // When/Then: the stale batch is rejected before a remote commit is created. + await expect( + store.commitBatch({ + ...batch(), + compareAndSwap: { expectedSequence: 2 }, + }), + ).rejects.toThrow("manifest compare-and-swap failed") + expect(octokit.createTree).not.toHaveBeenCalled() + }) +}) + +function batch(): GitDbCommitBatch { + return { + manifest, + mutations: [createTable, insertRow], + } +} + +function mockGitDatabaseBase(): void { + octokit.getRef.mockResolvedValue({ data: { object: { sha: "base-commit-sha" } } }) + octokit.getCommit.mockResolvedValue({ data: { tree: { sha: "base-tree-sha" } } }) + octokit.createTree.mockResolvedValue({ data: { sha: "new-tree-sha" } }) + octokit.createCommit.mockResolvedValue({ data: { sha: "new-commit-sha" } }) + octokit.updateRef.mockResolvedValue({ data: {} }) +} + +function treePaths(): readonly string[] { + return firstTreeRequest().tree.map((entry) => entry.path) +} + +function treeContents(): readonly string[] { + return firstTreeRequest().tree.map((entry) => entry.content) +} + +function firstTreeRequest(): z.infer { + const request: unknown = octokit.createTree.mock.calls[0]?.[0] + return TreeCreateRequestSchema.parse(request) +} + +class FakeGitHubStatusError extends Error { + constructor(readonly status: number) { + super(`GitHub status ${status}`) + } +} + +function encodedFile( + value: unknown, + sha: string, +): { readonly content: string; readonly sha: string } { + return { + content: Buffer.from(JSON.stringify(value), "utf8").toString("base64"), + sha, + } +} diff --git a/tests/github-write-retry.test.ts b/tests/github-write-retry.test.ts new file mode 100644 index 0000000..1a26e6c --- /dev/null +++ b/tests/github-write-retry.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { GitHubPlaintextStore } from "../src/github/github-plaintext-store.js" +import type { GitHubConfig } from "../src/github/types.js" + +const octokit = vi.hoisted(() => ({ + createCommit: vi.fn(), + createOrUpdateFileContents: vi.fn(), + createTree: vi.fn(), + getCommit: vi.fn(), + getContent: vi.fn(), + getRef: vi.fn(), + updateRef: vi.fn(), +})) + +vi.mock("@octokit/rest", () => ({ + Octokit: vi.fn(function MockOctokit() { + return { + git: { + createCommit: octokit.createCommit, + createTree: octokit.createTree, + getCommit: octokit.getCommit, + getRef: octokit.getRef, + updateRef: octokit.updateRef, + }, + repos: { + createOrUpdateFileContents: octokit.createOrUpdateFileContents, + getContent: octokit.getContent, + }, + } + }), +})) + +const config = { + branch: "main", + owner: "3x-haust", + prefix: "gitdb/v1", + repo: "gitdb-example-db", + token: "redacted-token", +} satisfies GitHubConfig + +describe("GitHub write retry behavior", () => { + beforeEach(() => { + octokit.createCommit.mockReset() + octokit.createOrUpdateFileContents.mockReset() + octokit.createTree.mockReset() + octokit.getCommit.mockReset() + octokit.getContent.mockReset() + octokit.getRef.mockReset() + octokit.updateRef.mockReset() + mockGitDatabaseBase() + }) + + it("retries tree commits after a GitHub conflict", async () => { + // Given: GitHub reports a stale branch ref on the first tree commit. + octokit.updateRef + .mockRejectedValueOnce(new FakeGitHubStatusError(409)) + .mockResolvedValueOnce({ data: {} }) + const store = new GitHubPlaintextStore(config) + + // When: a mutation segment is appended. + const segment = await store.appendMutation({ + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "INSERT INTO people VALUES ('p1', 'Lin')", + }) + + // Then: the store retries from a fresh ref without using Contents API writes. + expect(segment).toBe("00000000000000000001") + expect(octokit.getRef).toHaveBeenCalledTimes(2) + expect(octokit.updateRef).toHaveBeenCalledTimes(2) + expect(octokit.createOrUpdateFileContents).not.toHaveBeenCalled() + }) + + it("fails with a storage error after repeated GitHub conflicts", async () => { + // Given: GitHub keeps rejecting every tree commit ref update with conflict. + octokit.updateRef.mockRejectedValue(new FakeGitHubStatusError(409)) + const store = new GitHubPlaintextStore(config) + + // When/Then: the bounded retry loop reports a GitDB storage error. + await expect( + store.appendMutation({ + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "INSERT INTO people VALUES ('p1', 'Lin')", + }), + ).rejects.toThrow("GitHub tree commit did not settle") + expect(octokit.updateRef).toHaveBeenCalledTimes(5) + }) +}) + +class FakeGitHubStatusError extends Error { + constructor(readonly status: number) { + super(`GitHub status ${status}`) + } +} + +function mockGitDatabaseBase(): void { + octokit.getRef.mockResolvedValue({ data: { object: { sha: "base-commit-sha" } } }) + octokit.getCommit.mockResolvedValue({ data: { tree: { sha: "base-tree-sha" } } }) + octokit.createTree.mockResolvedValue({ data: { sha: "new-tree-sha" } }) + octokit.createCommit.mockResolvedValue({ data: { sha: "new-commit-sha" } }) + octokit.updateRef.mockResolvedValue({ data: {} }) +} diff --git a/tests/http-runtime.test.ts b/tests/http-runtime.test.ts deleted file mode 100644 index 18da4f7..0000000 --- a/tests/http-runtime.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest" -import { GitDbRuntime } from "../src/http/gitdb.runtime.js" - -describe("GitDbRuntime", () => { - it("reports starting health before the facade server is bootstrapped", () => { - // Given: a freshly constructed deployable runtime. - const runtime = new GitDbRuntime() - - // When: the control-plane health response is read before bootstrap. - const health = runtime.health() - - // Then: deploy health checks get a stable response shape. - expect(health).toEqual({ facade: null, mode: "local", status: "starting" }) - }) -}) diff --git a/tests/index-runtime.test.ts b/tests/index-runtime.test.ts new file mode 100644 index 0000000..c9c561b --- /dev/null +++ b/tests/index-runtime.test.ts @@ -0,0 +1,89 @@ +import { mkdtemp, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, expect, it } from "vitest" +import { createGitDbDataSource, defineEntity, GitDbDataSource } from "../src/orm/index.js" +import { GitDbEngine } from "../src/sql/engine.js" +import { LocalPlaintextStore } from "../src/storage/local-plaintext-store.js" + +type Person = { + readonly id: string + readonly name: string + readonly team_id: string +} + +const PersonEntity = defineEntity({ + columns: { + id: "STRING", + name: "STRING", + team_id: "STRING", + }, + indexes: [{ columns: ["team_id"], name: "people_team_id_idx" }], + primaryKey: "id", + tableName: "people", +}) + +describe("index runtime", () => { + it("uses primary and secondary indexes for equality lookup", async () => { + // Given: a repository has rows addressable by primary key and secondary equality metadata. + const root = await mkdtemp(join(tmpdir(), "gitdb-index-runtime-")) + const store = new LocalPlaintextStore({ root }) + const engine = await GitDbEngine.open({ store }) + const dataSource = new GitDbDataSource(engine) + await dataSource.synchronize(PersonEntity) + const people = dataSource.getRepository(PersonEntity) + await people.insertMany(peopleRows(40)) + const before = engine.getIndexStats() + + // When: repository find emits simple equality SELECT statements. + const byPrimary = await people.find({ where: { id: "p7" } }) + const bySecondary = await people.find({ where: { team_id: "storage" } }) + + // Then: both lookups use rebuilt equality indexes and match scan semantics. + expect(byPrimary).toEqual([{ id: "p7", name: "Person 7", team_id: "runtime" }]) + expect(bySecondary).toHaveLength(20) + expect(bySecondary[0]).toEqual({ id: "p0", name: "Person 0", team_id: "storage" }) + expect(engine.getIndexStats()).toMatchObject({ + indexedRows: 40, + lookupHits: before.lookupHits + 2, + }) + }) + + it("rebuilds stale indexes on reopen", async () => { + // Given: plaintext index artifacts are stale compared with the committed snapshot rows. + const root = await mkdtemp(join(tmpdir(), "gitdb-index-reopen-")) + const first = await createGitDbDataSource({ + entities: [PersonEntity], + store: new LocalPlaintextStore({ root }), + synchronize: true, + }) + await first.getRepository(PersonEntity).insertMany(peopleRows(12)) + await writeFile( + join(root, "gitdb/v1/people/indexes.json"), + JSON.stringify({ columns: {}, rowCount: 0, version: 1 }), + "utf8", + ) + + // When: the database reopens and runs the same equality lookup. + const reopened = await GitDbEngine.open({ store: new LocalPlaintextStore({ root }) }) + const dataSource = new GitDbDataSource(reopened) + const people = dataSource.getRepository(PersonEntity) + const storagePeople = await people.find({ where: { team_id: "storage" } }) + + // Then: stale index files are ignored and indexes rebuild from snapshot/log state. + expect(storagePeople).toHaveLength(6) + expect(storagePeople[0]).toEqual({ id: "p0", name: "Person 0", team_id: "storage" }) + expect(reopened.getIndexStats()).toMatchObject({ + indexedRows: 12, + lookupHits: 1, + }) + }) +}) + +function peopleRows(count: number): readonly Person[] { + return Array.from({ length: count }, (_, index) => ({ + id: `p${index}`, + name: `Person ${index}`, + team_id: index % 2 === 0 ? "storage" : "runtime", + })) +} diff --git a/tests/local-atomic-write.test.ts b/tests/local-atomic-write.test.ts new file mode 100644 index 0000000..fdf181b --- /dev/null +++ b/tests/local-atomic-write.test.ts @@ -0,0 +1,96 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, describe, expect, it } from "vitest" +import { writeFileAtomically } from "../src/storage/atomic-write.js" + +describe("atomic local writes", () => { + const roots: string[] = [] + + afterEach(async () => { + // Given: atomic write tests create isolated directories. + const pending = roots.splice(0, roots.length) + + // When: a test completes. + await Promise.all(pending.map((root) => rm(root, { force: true, recursive: true }))) + + // Then: no temp state leaks across tests. + expect(roots).toHaveLength(0) + }) + + it("does not leave torn manifest JSON visible after injected write failure", async () => { + // Given: an existing manifest file and a failure before atomic rename. + const root = await temporaryRoot(roots) + const manifestPath = join(root, "gitdb/v1/manifest.json") + await mkdir(join(root, "gitdb/v1"), { recursive: true }) + await writeFile(manifestPath, '{ "version": 1, "sequence": 1, "logSegments": [] }\n', "utf8") + + // When: the replacement write fails after the temp file receives bytes. + await expect( + writeFileAtomically( + manifestPath, + '{ "version": 1, "sequence": 2, "logSegments": ["partial"] }\n', + { + onStage: (stage) => { + if (stage === "afterTempWrite") { + throw new Error("injected temp-write failure") + } + }, + }, + ), + ).rejects.toThrow("injected temp-write failure") + + // Then: the visible manifest is still complete, parseable, and old. + const visible = await readFile(manifestPath, "utf8") + expect(JSON.parse(visible)).toEqual({ logSegments: [], sequence: 1, version: 1 }) + expect(visible).not.toContain("partial") + }) + + it("leaves the new valid file visible when failure happens after rename", async () => { + // Given: an existing JSON file and a failure after atomic rename. + const root = await temporaryRoot(roots) + const path = join(root, "state.json") + await writeFile(path, '{ "value": "old" }\n', "utf8") + + // When: the write fails after the target path has been replaced. + await expect( + writeFileAtomically(path, '{ "value": "new" }\n', { + onStage: (stage) => { + if (stage === "afterRename") { + throw new Error("injected post-rename failure") + } + }, + }), + ).rejects.toThrow("injected post-rename failure") + + // Then: readers see a complete new file, not a torn intermediate. + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ value: "new" }) + }) + + it("does not expose partial opaque payloads after injected write failure", async () => { + // Given: an existing encrypted payload-shaped file. + const root = await temporaryRoot(roots) + const path = join(root, "manifest.enc") + await writeFile(path, "old-ciphertext-payload", "utf8") + + // When: a replacement opaque payload fails before rename. + await expect( + writeFileAtomically(path, "new-ciphertext-payload-that-must-not-appear", { + onStage: (stage) => { + if (stage === "afterTempWrite") { + throw new Error("injected ciphertext failure") + } + }, + }), + ).rejects.toThrow("injected ciphertext failure") + + // Then: readers still see the previous complete payload. + await expect(readFile(path, "utf8")).resolves.toBe("old-ciphertext-payload") + }) +}) + +async function temporaryRoot(roots: string[]): Promise { + const root = await mkdtemp(join(tmpdir(), "gitdb-atomic-write-")) + roots.push(root) + return root +} diff --git a/tests/local-encrypted-store.test.ts b/tests/local-encrypted-store.test.ts new file mode 100644 index 0000000..46d1c1a --- /dev/null +++ b/tests/local-encrypted-store.test.ts @@ -0,0 +1,147 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, describe, expect, it } from "vitest" +import { createAesGcmCipher } from "../src/crypto/aes-gcm.js" +import { GitDbStorageError } from "../src/errors.js" +import { LocalEncryptedStore } from "../src/storage/local-encrypted-store.js" +import type { GitDbManifest, PersistedMutation } from "../src/types.js" +import { segmentId } from "../src/types.js" + +const key = Buffer.alloc(32, 13).toString("base64url") + +describe("LocalEncryptedStore", () => { + const roots: string[] = [] + + afterEach(async () => { + // Given: encrypted store tests create isolated roots. + const pending = roots.splice(0, roots.length) + + // When: the test finishes. + await Promise.all(pending.map((root) => rm(root, { force: true, recursive: true }))) + + // Then: no encrypted store state leaks between tests. + expect(roots).toHaveLength(0) + }) + + it("persists manifest and mutation logs as opaque encrypted files", async () => { + // Given: an encrypted store and one SQL mutation. + const root = await mkdtemp(join(tmpdir(), "gitdb-local-enc-")) + roots.push(root) + const store = new LocalEncryptedStore({ cipher: createAesGcmCipher(key), root }) + const mutation: PersistedMutation = { + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "CREATE TABLE people (id STRING, name STRING)", + } + const manifest: GitDbManifest = { + createdAt: mutation.at, + logSegments: [segmentId("00000000000000000001")], + sequence: 1, + updatedAt: mutation.at, + version: 1, + } + + // When: mutation and manifest are written, then read back through the store. + const segment = await store.appendMutation(mutation) + await store.writeManifest(manifest) + const reopenedManifest = await store.readManifest() + const reopenedMutations = await store.readMutations([segment]) + + // Then: public files are opaque while the store can decrypt its own state. + const manifestPayload = await readFile(join(root, "gitdb/v1/manifest.enc"), "utf8") + const logPayload = await readFile(join(root, "gitdb/v1/log/00000000000000000001.enc"), "utf8") + expect(manifestPayload).not.toContain("people") + expect(logPayload).not.toContain("CREATE TABLE") + expect(reopenedManifest).toEqual(manifest) + expect(reopenedMutations).toEqual([mutation]) + }) + + it("does not expose table or row text after batch commit", async () => { + // Given: an encrypted store with table and row mutations. + const root = await mkdtemp(join(tmpdir(), "gitdb-local-enc-opaque-")) + roots.push(root) + const store = new LocalEncryptedStore({ cipher: createAesGcmCipher(key), root }) + const createTable: PersistedMutation = { + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "CREATE TABLE people (id STRING, name STRING)", + } + const insertRow: PersistedMutation = { + at: "2026-06-14T00:00:01.000Z", + sequence: 2, + sql: "INSERT INTO people VALUES ('p1', 'Ada')", + } + const manifest: GitDbManifest = { + createdAt: createTable.at, + logSegments: [segmentId("00000000000000000001"), segmentId("00000000000000000002")], + sequence: 2, + updatedAt: insertRow.at, + version: 1, + } + + // When: the batch is committed through the local v2 store contract. + await store.commitBatch({ manifest, mutations: [createTable, insertRow] }) + + // Then: durable files do not reveal table names, SQL text, or row values. + const payloads = await Promise.all([ + readFile(join(root, "gitdb/v1/manifest.enc"), "utf8"), + readFile(join(root, "gitdb/v1/log/00000000000000000001.enc"), "utf8"), + readFile(join(root, "gitdb/v1/log/00000000000000000002.enc"), "utf8"), + ]) + for (const payload of payloads) { + expect(payload).not.toContain("people") + expect(payload).not.toContain("Ada") + expect(payload).not.toContain("CREATE TABLE") + expect(payload).not.toContain("INSERT INTO") + } + await expect(store.readMutations(manifest.logSegments)).resolves.toEqual([ + createTable, + insertRow, + ]) + }) + + it("throws a storage error when a manifest references a missing segment", async () => { + // Given: a store with no mutation files. + const root = await mkdtemp(join(tmpdir(), "gitdb-local-enc-missing-")) + roots.push(root) + const store = new LocalEncryptedStore({ cipher: createAesGcmCipher(key), root }) + + // When/Then: reading a missing segment is treated as storage corruption. + await expect(store.readMutations([segmentId("00000000000000000009")])).rejects.toThrow( + GitDbStorageError, + ) + }) + + it("does not expose table or row text in checkpoint files", async () => { + // Given: an encrypted store receives row-bearing SQL and checkpoint metadata. + const root = await mkdtemp(join(tmpdir(), "gitdb-local-enc-opaque-")) + roots.push(root) + const store = new LocalEncryptedStore({ cipher: createAesGcmCipher(key), root }) + const mutation: PersistedMutation = { + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "INSERT INTO people VALUES ('p1', 'Lin')", + } + + // When: durable encrypted files are written. + const segment = await store.appendMutation(mutation) + await store.writeManifest({ + createdAt: mutation.at, + logSegments: [segment], + sequence: 1, + updatedAt: mutation.at, + version: 1, + }) + await store.writeCheckpoint({ sequence: 1 }) + + // Then: filenames reveal only storage roles and payloads stay opaque. + const manifestPayload = await readFile(join(root, "gitdb/v1/manifest.enc"), "utf8") + const logPayload = await readFile(join(root, "gitdb/v1/log/00000000000000000001.enc"), "utf8") + const checkpointPayload = await readFile(join(root, "gitdb/v1/checkpoint.enc"), "utf8") + const combinedPayload = `${manifestPayload}\n${logPayload}\n${checkpointPayload}` + expect(combinedPayload).not.toContain("people") + expect(combinedPayload).not.toContain("Lin") + expect(combinedPayload).not.toContain("INSERT") + }) +}) diff --git a/tests/local-lock.test.ts b/tests/local-lock.test.ts new file mode 100644 index 0000000..94931d4 --- /dev/null +++ b/tests/local-lock.test.ts @@ -0,0 +1,152 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process" +import { once } from "node:events" +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" +import { hostname } from "node:os" +import { dirname, join } from "node:path" +import { setTimeout as delay } from "node:timers/promises" +import { afterEach, describe, expect, it } from "vitest" +import { z } from "zod" +import { GitDbEngine } from "../src/sql/engine.js" +import { LocalPlaintextStore } from "../src/storage/local-plaintext-store.js" + +const LockPayloadSchema = z.object({ + createdAt: z.string(), + heartbeatAt: z.string(), + hostname: z.string(), + pid: z.number(), + token: z.string(), +}) + +describe("local store writer locks", () => { + const roots: string[] = [] + + afterEach(async () => { + // Given: lock tests create isolated filesystem roots. + const pending = roots.splice(0, roots.length) + + // When: each test finishes. + await Promise.all(pending.map((root) => rm(root, { force: true, recursive: true }))) + + // Then: no local lock state leaks between process-level tests. + expect(roots).toHaveLength(0) + }) + + it("rejects concurrent writers to the same root", async () => { + // Given: another real process holds the writer lock for a local root. + const root = await mkdtemp(join("/tmp", "gitdb-lock-contention-")) + roots.push(root) + const lockPath = writerLockPath(root) + const readyPath = join(root, "holder.ready") + const holder = startLockHolder(lockPath, readyPath) + + try { + await waitForFile(readyPath) + const engine = await GitDbEngine.open({ store: new LocalPlaintextStore({ root }) }) + + // When/Then: a second writer fails before advancing the manifest boundary. + await expect(engine.execute("CREATE TABLE events (id STRING)")).rejects.toMatchObject({ + name: "GitDbLockTimeoutError", + }) + const manifest = await new LocalPlaintextStore({ root }).readManifest() + expect(manifest?.sequence ?? 0).toBe(0) + } finally { + await stopLockHolder(holder) + } + }) + + it("records owner metadata and recovers stale writer locks", async () => { + // Given: a local store acquires the writer lock itself. + const root = await mkdtemp(join("/tmp", "gitdb-lock-metadata-")) + roots.push(root) + const store = new LocalPlaintextStore({ root }) + const lock = await store.acquireLock({ staleAfterMs: 30_000, timeoutMs: 0 }) + const payload = LockPayloadSchema.parse( + JSON.parse(await readFile(writerLockPath(root), "utf8")), + ) + + // When/Then: the durable lock identifies its owner and heartbeat timestamp. + expect(payload).toMatchObject({ + hostname: hostname(), + pid: process.pid, + token: lock.token, + }) + expect(typeof payload["createdAt"]).toBe("string") + expect(typeof payload["heartbeatAt"]).toBe("string") + await lock.release() + + // Given: a stale lock is left behind by a dead writer. + await mkdir(dirname(writerLockPath(root)), { recursive: true }) + await writeFile( + writerLockPath(root), + JSON.stringify({ + createdAt: "2000-01-01T00:00:00.000Z", + heartbeatAt: "2000-01-01T00:00:00.000Z", + hostname: "stale-host", + pid: 0, + token: "stale-token", + }), + ) + + // When/Then: a new writer can recover the stale lock. + const recovered = await store.acquireLock({ staleAfterMs: 1, timeoutMs: 0 }) + expect(recovered.token).not.toBe("stale-token") + await recovered.release() + }) +}) + +function writerLockPath(root: string): string { + return join(root, "gitdb", "v1", "writer.lock") +} + +function startLockHolder(lockPath: string, readyPath: string): ChildProcessWithoutNullStreams { + const script = ` + const { mkdir, rm, writeFile } = await import("node:fs/promises") + const { hostname } = await import("node:os") + const { dirname } = await import("node:path") + const [lockPath, readyPath] = process.argv.slice(-2) + const token = "child-lock" + const payload = () => JSON.stringify({ + createdAt: new Date().toISOString(), + heartbeatAt: new Date().toISOString(), + hostname: hostname(), + pid: process.pid, + token, + }) + await mkdir(dirname(lockPath), { recursive: true }) + await writeFile(lockPath, payload()) + await writeFile(readyPath, "ready") + const heartbeat = setInterval(() => { + void writeFile(lockPath, payload()).catch(() => {}) + }, 50) + process.on("SIGTERM", () => { + clearInterval(heartbeat) + rm(lockPath, { force: true }).finally(() => process.exit(0)) + }) + setInterval(() => undefined, 1_000) + ` + return spawn(process.execPath, ["--input-type=module", "-e", script, lockPath, readyPath]) +} + +async function stopLockHolder(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null) { + return + } + child.kill("SIGTERM") + await once(child, "exit") +} + +async function waitForFile(path: string): Promise { + const deadline = Date.now() + 3_000 + while (Date.now() < deadline) { + try { + await stat(path) + return + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { + throw error + } + await delay(10) + } + } + throw new Error(`timed out waiting for ${path}`) +} diff --git a/tests/migration.test.ts b/tests/migration.test.ts new file mode 100644 index 0000000..31b1402 --- /dev/null +++ b/tests/migration.test.ts @@ -0,0 +1,145 @@ +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, expect, it } from "vitest" +import { GitDbStorageError } from "../src/errors.js" +import { GitDbEngine } from "../src/sql/engine.js" +import { LocalPlaintextStore } from "../src/storage/local-plaintext-store.js" +import { segmentId } from "../src/types.js" + +describe("storage migration and recovery", () => { + it("opens v1 stores and commits next write with v2 metadata", async () => { + // Given: an existing manifest v1 store with one committed schema mutation. + const root = await mkdtemp(join(tmpdir(), "gitdb-migrate-v1-")) + await writeV1Fixture(root, [ + { + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "CREATE TABLE accounts (id STRING, balance INT)", + }, + ]) + + // When: the engine opens the v1 store and commits the next mutation. + const engine = await GitDbEngine.open({ store: new LocalPlaintextStore({ root }) }) + await engine.execute("INSERT INTO accounts VALUES ('a1', 10)") + + // Then: the manifest is upgraded only after the successful commit. + const rows = await engine.execute("SELECT id, balance FROM accounts") + const manifest = JSON.parse(await readFile(join(root, "gitdb/v1/manifest.json"), "utf8")) + expect(rows.rows).toEqual([{ balance: 10, id: "a1" }]) + expect(manifest.version).toBe(2) + expect(manifest.metadata).toMatchObject({ migratedFrom: 1, storageEngine: "gitdb" }) + }) + + it("throws storage error with path context for corrupted manifest", async () => { + // Given: a local store whose manifest file is not valid JSON. + const root = await mkdtemp(join(tmpdir(), "gitdb-corrupt-manifest-")) + await mkdir(join(root, "gitdb/v1"), { recursive: true }) + await writeFile(join(root, "gitdb/v1/manifest.json"), "{ not json", "utf8") + + // When/Then: opening does not silently treat corruption as an empty database. + await expect(GitDbEngine.open({ store: new LocalPlaintextStore({ root }) })).rejects.toThrow( + GitDbStorageError, + ) + await expect(GitDbEngine.open({ store: new LocalPlaintextStore({ root }) })).rejects.toThrow( + "manifest.json", + ) + }) + + it("ignores orphan unmanifested mutation segments on reopen", async () => { + // Given: a committed v1 manifest plus an extra log segment not listed by the manifest. + const root = await mkdtemp(join(tmpdir(), "gitdb-orphan-log-")) + await writeV1Fixture(root, [ + { + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "CREATE TABLE accounts (id STRING, balance INT)", + }, + ]) + await writeMutation(root, { + at: "2026-06-14T00:00:01.000Z", + sequence: 2, + sql: "INSERT INTO accounts VALUES ('orphan', 999)", + }) + + // When: the engine reopens and commits a valid row. + const engine = await GitDbEngine.open({ store: new LocalPlaintextStore({ root }) }) + await engine.execute("INSERT INTO accounts VALUES ('a1', 10)") + const reopened = await GitDbEngine.open({ store: new LocalPlaintextStore({ root }) }) + + // Then: the unmanifested segment is ignored and never becomes visible. + const rows = await reopened.execute("SELECT id, balance FROM accounts ORDER BY id") + expect(rows.rows).toEqual([{ balance: 10, id: "a1" }]) + }) + + it("replays manifest logs instead of stale visible snapshot checkpoints", async () => { + // Given: a manifest sequence ahead of a stale visible snapshot checkpoint. + const root = await mkdtemp(join(tmpdir(), "gitdb-stale-snapshot-")) + await writeV1Fixture(root, [ + { + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "CREATE TABLE accounts (id STRING, balance INT)", + }, + { + at: "2026-06-14T00:00:01.000Z", + sequence: 2, + sql: "INSERT INTO accounts VALUES ('a1', 10)", + }, + ]) + const store = new LocalPlaintextStore({ root }) + await store.writeVisibleSnapshot({ + sequence: 1, + tables: [{ columns: ["id", "balance"], name: "accounts", rows: [] }], + }) + + // When: the engine opens the store. + const engine = await GitDbEngine.open({ store }) + + // Then: manifest replay wins over the stale checkpoint. + const rows = await engine.execute("SELECT id, balance FROM accounts") + expect(rows.rows).toEqual([{ balance: 10, id: "a1" }]) + }) +}) + +type FixtureMutation = { + readonly at: string + readonly sequence: number + readonly sql: string +} + +async function writeV1Fixture(root: string, mutations: readonly FixtureMutation[]): Promise { + await mkdir(join(root, "gitdb/v1/log"), { recursive: true }) + for (const mutation of mutations) { + await writeMutation(root, mutation) + } + const last = mutations[mutations.length - 1] + await writeFile( + join(root, "gitdb/v1/manifest.json"), + JSON.stringify( + { + createdAt: mutations[0]?.at ?? "2026-06-14T00:00:00.000Z", + logSegments: mutations.map((mutation) => segmentIdForFixture(mutation.sequence)), + sequence: last?.sequence ?? 0, + updatedAt: last?.at ?? "2026-06-14T00:00:00.000Z", + version: 1, + }, + null, + 2, + ), + "utf8", + ) +} + +async function writeMutation(root: string, mutation: FixtureMutation): Promise { + await mkdir(join(root, "gitdb/v1/log"), { recursive: true }) + await writeFile( + join(root, "gitdb/v1/log", `${segmentIdForFixture(mutation.sequence)}.json`), + JSON.stringify(mutation, null, 2), + "utf8", + ) +} + +function segmentIdForFixture(sequence: number) { + return segmentId(sequence.toString().padStart(20, "0")) +} diff --git a/tests/no-postgres-facade.test.ts b/tests/no-postgres-facade.test.ts new file mode 100644 index 0000000..dd85ccd --- /dev/null +++ b/tests/no-postgres-facade.test.ts @@ -0,0 +1,223 @@ +import { access, readdir, readFile, stat } from "node:fs/promises" +import { join } from "node:path" +import { describe, expect, it } from "vitest" +import packageJson from "../package.json" with { type: "json" } + +const removedRuntimePackages = [ + "@nestjs/common", + "@nestjs/core", + "@nestjs/platform-express", + "@prisma/adapter-pg", + "@prisma/client", + "@prisma/client-runtime-utils", + "@types/express", + "@types/pg", + "express", + "pg", + "pg-server", + "prisma", + "reflect-metadata", + "rxjs", +] as const + +const removedLockfileTerms = [ + "@nestjs/platform-express", + "@prisma/", + "@types/express", + "@types/pg", + "express@5", + "pg-protocol", + "pg-server", + "pg@8", + "prisma@", +] as const + +const removedPublicTerms = [ + "PostgreSQL", + "Prisma", + "createGitDbServer", + "express-prisma", + "facade", + "pg-server", + "postgres facade", + "postgresql://", + "start:facade", +] as const + +const publicRoots = [ + "README.md", + "docs", + "examples", + "package.json", + "pnpm-lock.yaml", + "scripts", + "site", + ".github/workflows", +] as const + +const publicTextExtensions = [ + ".css", + ".html", + ".js", + ".json", + ".md", + ".mjs", + ".svg", + ".yaml", + ".yml", +] as const + +function isMissingPath(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT" +} + +async function exists(path: string): Promise { + try { + await access(path) + return true + } catch (error) { + if (isMissingPath(error)) { + return false + } + throw error + } +} + +function isPublicTextFile(path: string): boolean { + return publicTextExtensions.some((extension) => path.endsWith(extension)) +} + +async function collectPublicFiles(path: string): Promise { + if (!(await exists(path))) { + return [] + } + + const metadata = await stat(path) + if (metadata.isFile()) { + return isPublicTextFile(path) ? [path] : [] + } + if (!metadata.isDirectory()) { + return [] + } + + const entries = await readdir(path, { withFileTypes: true }) + const files: string[] = [] + for (const entry of entries) { + const child = join(path, entry.name) + if (entry.isDirectory()) { + files.push(...(await collectPublicFiles(child))) + continue + } + if (entry.isFile() && isPublicTextFile(child)) { + files.push(child) + } + } + return files +} + +function findRemovedTerms(file: string, text: string): readonly string[] { + const normalizedText = text.toLowerCase() + return removedPublicTerms + .filter((term) => normalizedText.includes(term.toLowerCase())) + .map((term) => `${file} still contains ${term}`) +} + +describe("PostgreSQL facade removal", () => { + it("removes obsolete packages and scripts from package metadata", () => { + const dependencies = packageJson.dependencies ?? {} + const devDependencies = packageJson.devDependencies ?? {} + const scripts = packageJson.scripts ?? {} + + for (const dependency of removedRuntimePackages) { + expect(dependencies).not.toHaveProperty(dependency) + expect(devDependencies).not.toHaveProperty(dependency) + } + for (const [name, command] of Object.entries(scripts)) { + expect(name).not.toMatch(/facade|express-prisma/i) + expect(command).not.toMatch(/facade|prisma|postgres|pg\b/i) + } + expect(scripts.example).toBe("corepack pnpm example:api") + expect(scripts["example:api"]).toBe( + "corepack pnpm build && node examples/api-local/index.mjs && node examples/api-encrypted/index.mjs && node examples/api-encrypted-reopen/index.mjs", + ) + expect(scripts["example:api-local"]).toBe( + "corepack pnpm build && node examples/api-local/index.mjs", + ) + expect(scripts["example:api-encrypted"]).toBe( + "corepack pnpm build && node examples/api-encrypted/index.mjs", + ) + expect(scripts["example:api-encrypted-reopen"]).toBe( + "corepack pnpm build && node examples/api-encrypted-reopen/index.mjs", + ) + expect(scripts).not.toHaveProperty("example:local-runtime") + }) + + it("removes the old wire-protocol service and generated ORM example files", async () => { + const removedPaths = [ + "src/protocol", + "src/http", + "examples/express-prisma", + "examples/local-runtime", + ] + const requiredPaths = [ + "examples/api-local/index.mjs", + "examples/api-encrypted/index.mjs", + "examples/api-encrypted-reopen/index.mjs", + ] + + for (const path of removedPaths) { + await expect(exists(path)).resolves.toBe(false) + } + for (const path of requiredPaths) { + await expect(exists(path)).resolves.toBe(true) + } + }) + + it("removes stale generated-example paths from tool config", async () => { + for (const file of ["biome.json", ".gitignore"]) { + const config = await readFile(file, "utf8") + expect(config, `${file} still ignores the removed example`).not.toContain( + "examples/express-prisma", + ) + } + }) + + it("keeps public docs, site evidence, and lockfile free of facade positioning", async () => { + const publicFiles = ( + await Promise.all(publicRoots.map((root) => collectPublicFiles(root))) + ).flat() + + for (const file of publicFiles) { + const text = await readFile(file, "utf8") + expect(findRemovedTerms(file, text)).toEqual([]) + } + + const lockfile = await readFile("pnpm-lock.yaml", "utf8") + for (const term of removedLockfileTerms) { + expect(lockfile, `pnpm-lock.yaml still contains ${term}`).not.toContain(term) + } + }) + + it("detects removed facade wording in any new public text file", () => { + // Given: a future public file can be added outside the historical fixed list. + const fixturePath = "site/new-public-page.md" + const fixtureText = "This would reintroduce a PostgreSQL facade claim." + + // When: the facade detector scans that public text. + const violations = findRemovedTerms(fixturePath, fixtureText) + + // Then: the violation is reported with the file path. + expect(violations).toEqual([ + `${fixturePath} still contains PostgreSQL`, + `${fixturePath} still contains facade`, + ]) + }) + + it("exports only the first-party runtime, storage engine, and repository API", async () => { + const index = await readFile("src/index.ts", "utf8") + expect(index).not.toContain("createGitDbServer") + expect(index).not.toContain("./protocol/") + expect(index).toContain("createGitDbDataSource") + expect(index).toContain("GitDbEngine") + }) +}) diff --git a/tests/orm.test.ts b/tests/orm.test.ts new file mode 100644 index 0000000..f71f85c --- /dev/null +++ b/tests/orm.test.ts @@ -0,0 +1,192 @@ +import { mkdtemp } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, expect, it } from "vitest" +import { createGitDbDataSource, defineEntity, type EntityDefinition } from "../src/orm/index.js" +import { LocalPlaintextStore } from "../src/storage/local-plaintext-store.js" +import { CountingBatchStore } from "./sql-engine-fixtures.js" + +type Person = { + readonly id: string + readonly name: string + readonly team_id: string +} + +type LoosePerson = { + readonly id: string + readonly name: string | { readonly nested: boolean } + readonly team_id: string +} + +const PersonEntity = defineEntity({ + columns: { + id: "STRING", + name: "STRING", + team_id: "STRING", + }, + primaryKey: "id", + tableName: "people", +}) + +const LoosePersonEntity = defineEntity({ + columns: { + id: "STRING", + name: "STRING", + team_id: "STRING", + }, + primaryKey: "id", + tableName: "people", +}) + +describe("GitDB ORM", () => { + it("rejects unsupported JSON column metadata", () => { + // Given: entity metadata that claims a JSON column. + const entity = { + columns: { + id: "STRING", + metadata: "JSON", + }, + primaryKey: "id", + tableName: "people", + } as unknown as EntityDefinition<{ readonly id: string; readonly metadata: string }> + + // When/Then: the ORM refuses a type it cannot persist faithfully yet. + expect(() => defineEntity(entity)).toThrow() + }) + + it("validates secondary index metadata", () => { + // Given: entity metadata with a declared equality-only secondary index. + const indexed = defineEntity({ + columns: { + id: "STRING", + name: "STRING", + team_id: "STRING", + }, + indexes: [{ columns: ["team_id"], name: "people_team_id_idx" }], + primaryKey: "id", + tableName: "people", + }) + + // When/Then: valid index metadata is preserved for the later index runtime. + expect(indexed.indexes).toEqual([{ columns: ["team_id"], name: "people_team_id_idx" }]) + expect(() => + defineEntity>({ + columns: { + id: "STRING", + name: "STRING", + team_id: "STRING", + }, + indexes: [{ columns: ["missing"], name: "people_missing_idx" }], + primaryKey: "id", + tableName: "people", + }), + ).toThrow("indexed column missing does not exist") + }) + + it("saves, finds, and deletes entities through a TypeORM-style repository", async () => { + // Given: a GitDB data source synchronized from entity metadata. + const root = await mkdtemp(join(tmpdir(), "gitdb-orm-")) + const dataSource = await createGitDbDataSource({ + entities: [PersonEntity], + store: new LocalPlaintextStore({ root }), + synchronize: true, + }) + const people = dataSource.getRepository(PersonEntity) + + // When: callers use repository methods instead of raw SQL. + await people.save({ id: "p1", name: "Lin", team_id: "storage" }) + await people.save({ id: "p2", name: "Ada", team_id: "runtime" }) + const found = await people.find({ where: { team_id: "storage" } }) + const one = await people.findOne({ where: { id: "p2" } }) + await people.delete({ id: "p1" }) + + // Then: the repository exposes typed CRUD ergonomics over the local runtime. + await expect(people.find()).resolves.toEqual([{ id: "p2", name: "Ada", team_id: "runtime" }]) + expect(found).toEqual([{ id: "p1", name: "Lin", team_id: "storage" }]) + expect(one).toEqual({ id: "p2", name: "Ada", team_id: "runtime" }) + }) + + it("inserts many entities in one repository transaction", async () => { + // Given: a synchronized repository backed by a counting v2 store. + const root = await mkdtemp(join(tmpdir(), "gitdb-orm-bulk-")) + const store = new CountingBatchStore(new LocalPlaintextStore({ root })) + const dataSource = await createGitDbDataSource({ + entities: [PersonEntity], + store, + synchronize: true, + }) + const people = dataSource.getRepository(PersonEntity) + store.resetCounts() + + // When: callers insert multiple rows through the repository bulk API. + const result = await people.insertMany([ + { id: "p1", name: "Lin", team_id: "storage" }, + { id: "p2", name: "Ada", team_id: "runtime" }, + ]) + + // Then: rows are visible and persistence used one engine batch boundary. + expect(result).toEqual({ inserted: 2 }) + await expect(people.find()).resolves.toEqual([ + { id: "p1", name: "Lin", team_id: "storage" }, + { id: "p2", name: "Ada", team_id: "runtime" }, + ]) + expect(store.commitBatchCalls).toBe(1) + expect(store.writeManifestCalls).toBeLessThanOrEqual(3) + }) + + it("treats empty and invalid bulk inserts as atomic repository operations", async () => { + // Given: a synchronized repository with no rows. + const root = await mkdtemp(join(tmpdir(), "gitdb-orm-bulk-invalid-")) + const store = new CountingBatchStore(new LocalPlaintextStore({ root })) + const dataSource = await createGitDbDataSource({ + entities: [PersonEntity], + store, + synchronize: true, + }) + const people = dataSource.getRepository(PersonEntity) + const loosePeople = dataSource.getRepository(LoosePersonEntity) + store.resetCounts() + + // When/Then: empty insertMany is a no-op and does not persist. + await expect(people.insertMany([])).resolves.toEqual({ inserted: 0 }) + expect(store.commitBatchCalls).toBe(0) + + // When/Then: invalid rows reject and leave no partial rows visible. + await expect( + loosePeople.insertMany([ + { id: "p1", name: "Lin", team_id: "storage" }, + { id: "bad", name: { nested: true }, team_id: "runtime" }, + ]), + ).rejects.toThrow("entity value for name") + await expect(people.find()).resolves.toEqual([]) + }) + + it("saves many entities with upsert semantics in one repository transaction", async () => { + // Given: existing rows and a counting store. + const root = await mkdtemp(join(tmpdir(), "gitdb-orm-save-many-")) + const store = new CountingBatchStore(new LocalPlaintextStore({ root })) + const dataSource = await createGitDbDataSource({ + entities: [PersonEntity], + store, + synchronize: true, + }) + const people = dataSource.getRepository(PersonEntity) + await people.insertMany([{ id: "p1", name: "Lin", team_id: "storage" }]) + const updates = Array.from({ length: 100 }, (_, index) => ({ + id: `p${index + 1}`, + name: `Person ${index + 1}`, + team_id: index % 2 === 0 ? "storage" : "runtime", + })) + store.resetCounts() + + // When: saveMany mixes overwrite and insert rows. + const result = await people.saveMany(updates) + + // Then: saveMany preserves save() overwrite semantics inside one batch. + const rows = await dataSource.query("SELECT * FROM people WHERE id = 'p1'") + expect(result).toEqual({ saved: 100 }) + expect(rows).toEqual([{ id: "p1", name: "Person 1", team_id: "storage" }]) + expect(store.commitBatchCalls).toBe(1) + expect(store.writeManifestCalls).toBeLessThanOrEqual(3) + }) +}) diff --git a/tests/package-metadata.test.ts b/tests/package-metadata.test.ts new file mode 100644 index 0000000..a76f535 --- /dev/null +++ b/tests/package-metadata.test.ts @@ -0,0 +1,157 @@ +import { readFile } from "node:fs/promises" +import { describe, expect, it } from "vitest" +import packageJson from "../package.json" with { type: "json" } + +describe("npm publish metadata", () => { + it("declares package root exports, types, and public publish config", () => { + // Given: npm consumers install the built package by its root entrypoint. + const metadata = packageJson + + // When: publish metadata is inspected before packing. + const rootExport = metadata.exports["."] + + // Then: Node and TypeScript consumers get an explicit package surface. + expect(metadata.main).toBe("./dist/src/index.js") + expect(metadata.types).toBe("./dist/src/index.d.ts") + expect(metadata.homepage).toBe("https://3x-haust.github.io/gitdb/") + expect(metadata.bin).toEqual({ gitdb: "dist/src/cli/main.js" }) + expect(metadata.files).toEqual(["dist/src", "README.md", "docs", "examples"]) + expect(metadata.publishConfig).toEqual({ access: "public", provenance: true }) + expect(metadata.repository.url).toBe("git+https://github.com/3x-haust/gitdb.git") + expect(rootExport).toEqual({ + import: "./dist/src/index.js", + types: "./dist/src/index.d.ts", + }) + }) + + it("declares dry-run publish scripts that do not publish to npm", () => { + // Given: release validation must be credential-safe. + const scripts = packageJson.scripts + + // When: publish scripts are inspected. + const pack = scripts["pack:dry-run"] + const publish = scripts["publish:dry-run"] + + // Then: both commands validate package contents without publishing. + expect(pack).toBe("corepack pnpm build && COREPACK_ENABLE_STRICT=0 npm pack --dry-run --json") + expect(publish).toBe( + "corepack pnpm build && COREPACK_ENABLE_STRICT=0 npm publish --dry-run --access public", + ) + }) + + it("defines a local CI workflow for the runtime contract", async () => { + // Given: CI is the first public contract gate before storage-engine changes. + const workflow = await readFile(".github/workflows/ci.yml", "utf8") + + // When: the workflow is inspected locally. + const requiredCommands = [ + "corepack pnpm check", + "corepack pnpm test", + "corepack pnpm build", + "GITDB_BENCH_ROWS=25 corepack pnpm benchmark:gate", + "corepack pnpm pack:dry-run", + ] as const + + // Then: every gate needed for the baseline rebuild is enforced by CI. + for (const command of requiredCommands) { + expect(workflow).toContain(command) + } + expect(workflow).toContain("package-manager-cache: false") + expect(workflow).not.toContain("cache: pnpm") + expect(workflow).not.toContain("npm publish") + }) + + it("defines a credential-gated release workflow for npm publishing", async () => { + // Given: npm publishing must use OIDC and must not require npm tokens in CI. + const workflow = await readFile(".github/workflows/release.yml", "utf8") + + // When: the release workflow is inspected locally. + const requiredTerms = [ + "workflow_dispatch:", + "tags:", + "id-token: write", + "environment: npm-publish", + "startsWith(github.ref, 'refs/tags/v')", + "inputs.publish", + "corepack pnpm publish:dry-run", + "COREPACK_ENABLE_STRICT=0 npm publish --access public", + "package-manager-cache: false", + ] as const + + // Then: package validation is separate from credential-gated live publishing. + for (const term of requiredTerms) { + expect(workflow).toContain(term) + } + expect(workflow).not.toContain("NPM_TOKEN") + }) + + it("defines a dedicated benchmark gate command", () => { + // Given: CI must validate benchmark evidence instead of only running a benchmark. + const scripts = packageJson.scripts + + // When: the benchmark gate command is inspected. + const command = scripts["benchmark:gate"] + + // Then: the gate builds first and runs the dedicated validator. + expect(command).toBe("corepack pnpm build && node scripts/benchmark-gate.mjs") + }) + + it("keeps public API exports documented by the migration guide", async () => { + // Given: root exports are the stable npm API consumers compile against. + const index = await readFile("src/index.ts", "utf8") + const migration = await readFile("docs/MIGRATION.md", "utf8") + const publicSymbols = [ + "createGitDbDataSource", + "defineEntity", + "GitDbDataSource", + "GitDbRepository", + "GitDbEntityIndexDefinition", + "GitDbInsertResult", + "GitDbSaveManyResult", + "GitDbEngine", + "LocalPlaintextStore", + "LocalEncryptedStore", + "GitHubEncryptedStore", + "GitDbStore", + "GitDbStoreV2", + "GitDbCommitBatch", + "GitDbStoreCapabilities", + "GitDbDurabilityMode", + "GitDbEngineOptions", + "SnapshotPolicy", + "commitBatchThroughStore", + ] as const + + // When/Then: removing a public symbol requires updating migration documentation too. + for (const symbol of publicSymbols) { + expect(index).toContain(symbol) + expect(migration).toContain(symbol) + } + }) + + it("documents v1 to v2 storage migration policy", async () => { + // Given: storage format claims are part of the release contract. + const migration = await readFile("docs/MIGRATION.md", "utf8") + + // When/Then: the guide states compatibility, migration behavior, and rollback terms. + expect(migration).toContain("manifest v1") + expect(migration).toContain("store contract v2") + expect(migration).toContain("rollback") + }) + + it("keeps the npm package ESM-only and scoped to runtime docs", () => { + // Given: npm package metadata is the install contract. + const metadata = packageJson + const rootExport = metadata.exports["."] + + // When/Then: consumers get an ESM package without test/site/evidence payloads. + expect(metadata.type).toBe("module") + expect(rootExport).not.toHaveProperty("require") + expect(metadata.files).toEqual(["dist/src", "README.md", "docs", "examples"]) + expect(metadata.files).not.toContain("site") + expect(metadata.files).not.toContain("tests") + expect(metadata.files).not.toContain("plans") + expect(metadata.files).not.toContain("evidence") + expect(metadata.files).not.toContain(".omo") + }) +}) diff --git a/tests/page-snapshot.test.ts b/tests/page-snapshot.test.ts new file mode 100644 index 0000000..e2cf01f --- /dev/null +++ b/tests/page-snapshot.test.ts @@ -0,0 +1,182 @@ +import { mkdtemp, readFile, stat } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { GitHubPlaintextStore } from "../src/github/github-plaintext-store.js" +import type { GitHubConfig } from "../src/github/types.js" +import { LocalPlaintextStore } from "../src/storage/local-plaintext-store.js" +import type { GitDbManifest, PersistedMutation, SqlRow } from "../src/types.js" +import { segmentId } from "../src/types.js" + +const octokit = vi.hoisted(() => ({ + createCommit: vi.fn(), + createOrUpdateFileContents: vi.fn(), + createTree: vi.fn(), + getCommit: vi.fn(), + getContent: vi.fn(), + getRef: vi.fn(), + updateRef: vi.fn(), +})) + +vi.mock("@octokit/rest", () => ({ + Octokit: vi.fn(function MockOctokit() { + return { + git: { + createCommit: octokit.createCommit, + createTree: octokit.createTree, + getCommit: octokit.getCommit, + getRef: octokit.getRef, + updateRef: octokit.updateRef, + }, + repos: { + createOrUpdateFileContents: octokit.createOrUpdateFileContents, + getContent: octokit.getContent, + }, + } + }), +})) + +const config = { + branch: "main", + owner: "3x-haust", + prefix: "gitdb/v1", + repo: "gitdb-example-db", + token: "redacted-token", +} satisfies GitHubConfig + +describe("page-level visible snapshots", () => { + beforeEach(() => { + octokit.createCommit.mockReset() + octokit.createOrUpdateFileContents.mockReset() + octokit.createTree.mockReset() + octokit.getCommit.mockReset() + octokit.getContent.mockReset() + octokit.getRef.mockReset() + octokit.updateRef.mockReset() + mockGitDatabaseBase() + }) + + it("writes only changed table pages before checkpoint", async () => { + // Given: a local plaintext snapshot spans three row pages. + const root = await mkdtemp(join(tmpdir(), "gitdb-page-snapshot-")) + const store = new LocalPlaintextStore({ root, visibleSnapshotPageSizeBytes: 1 }) + await store.writeVisibleSnapshot({ + sequence: 1, + tables: [{ columns: ["id", "label"], name: "events", rows: rows(3) }], + }) + const firstPage = join(root, "gitdb/v1/events/pages/000000.json") + const secondPage = join(root, "gitdb/v1/events/pages/000001.json") + const thirdPage = join(root, "gitdb/v1/events/pages/000002.json") + const beforeFirst = await stat(firstPage) + const beforeSecond = await stat(secondPage) + const beforeThird = await stat(thirdPage) + + // When: one row in the second page changes. + await store.writeVisibleSnapshot({ + sequence: 2, + tables: [{ columns: ["id", "label"], name: "events", rows: changedRows(3, 1) }], + }) + + // Then: unchanged page files are not rewritten while the checkpoint advances. + expect((await stat(firstPage)).ino).toBe(beforeFirst.ino) + expect((await stat(secondPage)).ino).not.toBe(beforeSecond.ino) + expect((await stat(thirdPage)).ino).toBe(beforeThird.ino) + expect(await readFile(join(root, "gitdb/v1/snapshot.json"), "utf8")).toContain('"sequence": 2') + }) + + it("ignores page snapshots when the checkpoint is missing or stale", async () => { + // Given: page files exist but the checkpoint does not match the manifest. + const root = await mkdtemp(join(tmpdir(), "gitdb-stale-page-snapshot-")) + const store = new LocalPlaintextStore({ root }) + const mutation: PersistedMutation = { + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "CREATE TABLE events (id STRING, label STRING)", + } + const manifest: GitDbManifest = { + createdAt: mutation.at, + logSegments: [segmentId("00000000000000000001")], + sequence: 1, + updatedAt: mutation.at, + version: 1, + } + await store.writeVisibleSnapshot({ + sequence: 0, + tables: [{ columns: ["id", "label"], name: "events", rows: rows(2) }], + }) + await store.appendMutation(mutation) + await store.writeManifest(manifest) + + // When: the store reads the derived snapshot. + const snapshot = await store.readVisibleSnapshot() + + // Then: callers can reject it because the checkpoint sequence is stale. + expect(snapshot?.sequence).toBe(0) + expect(snapshot?.tables[0]?.rows).toHaveLength(2) + }) + + it("writes GitHub plaintext pages through one tree commit", async () => { + // Given: a GitHub plaintext store receives a multi-page visible snapshot. + const store = new GitHubPlaintextStore(config, { visibleSnapshotPageSizeBytes: 1 }) + + // When: snapshot pages are written remotely. + await store.writeVisibleSnapshot({ + sequence: 7, + tables: [{ columns: ["id", "label"], name: "events", rows: rows(3) }], + }) + + // Then: one tree commit carries page files, page metadata, schema, and checkpoint. + expect(octokit.createTree).toHaveBeenCalledTimes(1) + expect(octokit.createOrUpdateFileContents).not.toHaveBeenCalled() + expect(treePaths()).toEqual([ + "gitdb/v1/events/schema.json", + "gitdb/v1/events/pages/000000.json", + "gitdb/v1/events/pages/000001.json", + "gitdb/v1/events/pages/000002.json", + "gitdb/v1/events/pages.json", + "gitdb/v1/events/indexes.json", + "gitdb/v1/snapshot.json", + ]) + expect(treeContentFor("gitdb/v1/events/pages/000001.json")).toContain("event 1") + }) +}) + +function rows(count: number): readonly SqlRow[] { + return Array.from({ length: count }, (_, index) => ({ + id: `e${index}`, + label: `event ${index}`, + })) +} + +function changedRows(count: number, changedIndex: number): readonly SqlRow[] { + return rows(count).map((row, index) => + index === changedIndex ? { ...row, label: `changed ${changedIndex}` } : row, + ) +} + +function mockGitDatabaseBase(): void { + octokit.getRef.mockResolvedValue({ data: { object: { sha: "base-commit-sha" } } }) + octokit.getCommit.mockResolvedValue({ data: { tree: { sha: "base-tree-sha" } } }) + octokit.createTree.mockResolvedValue({ data: { sha: "new-tree-sha" } }) + octokit.createCommit.mockResolvedValue({ data: { sha: "new-commit-sha" } }) + octokit.updateRef.mockResolvedValue({ data: {} }) +} + +function treePaths(): readonly string[] { + return treeEntries().map((entry) => entry.path) +} + +function treeContentFor(path: string): string { + return treeEntries().find((entry) => entry.path === path)?.content ?? "" +} + +function treeEntries(): readonly { readonly content: string; readonly path: string }[] { + const request = octokit.createTree.mock.calls[0]?.[0] + if (request === undefined || !Array.isArray(request.tree)) { + throw new TypeError("expected createTree request with tree entries") + } + return request.tree.map((entry: { readonly content: string; readonly path: string }) => ({ + content: entry.content, + path: entry.path, + })) +} diff --git a/tests/plaintext-codec.test.ts b/tests/plaintext-codec.test.ts new file mode 100644 index 0000000..014ada5 --- /dev/null +++ b/tests/plaintext-codec.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest" +import { + parsePlaintextManifest, + parsePlaintextMutation, + parseVisibleDatabaseSnapshot, + parseVisibleTableRows, + parseVisibleTableSchema, + segmentIdForSequence, + stringifyPlaintext, +} from "../src/storage/plaintext-codec.js" + +describe("plaintext codec", () => { + it("round-trips readable manifest and mutation payloads", () => { + // Given: public plaintext JSON payloads from a local store. + const manifest = stringifyPlaintext({ + createdAt: "2026-06-14T00:00:00.000Z", + logSegments: ["00000000000000000001"], + sequence: 1, + updatedAt: "2026-06-14T00:00:00.000Z", + version: 1, + }) + const mutation = stringifyPlaintext({ + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "INSERT INTO people VALUES ('p1', 'Lin')", + }) + + // When: the codec parses the durable files. + const parsedManifest = parsePlaintextManifest(manifest) + const parsedMutation = parsePlaintextMutation(mutation) + + // Then: branded segment ids and SQL text are preserved. + expect(parsedManifest.logSegments).toEqual(["00000000000000000001"]) + expect(parsedMutation.sql).toBe("INSERT INTO people VALUES ('p1', 'Lin')") + }) + + it("parses visible table files and optional snapshot checkpoints", () => { + // Given: public table dashboard files. + const schema = '{ "name": "people", "columns": ["id", "name"] }' + const rows = '[{ "id": "p1", "name": "Lin" }]' + const snapshot = + '{ "sequence": 7, "tables": [{ "name": "people", "columns": ["id"], "rows": [] }] }' + + // When: each public file is parsed. + const parsedSchema = parseVisibleTableSchema(schema) + const parsedRows = parseVisibleTableRows(rows) + const parsedSnapshot = parseVisibleDatabaseSnapshot(snapshot) + + // Then: schema, rows, and checkpoint sequence remain distinct concepts. + expect(parsedSchema).toEqual({ columns: ["id", "name"], name: "people" }) + expect(parsedRows).toEqual([{ id: "p1", name: "Lin" }]) + expect(parsedSnapshot.sequence).toBe(7) + }) + + it("formats mutation segment ids with fixed-width ordering", () => { + // Given: mutation sequence numbers are the WAL ordering key. + const sequence = 42 + + // When: the sequence is converted to a segment id. + const segment = segmentIdForSequence(sequence) + + // Then: lexical ordering matches numeric ordering. + expect(segment).toBe("00000000000000000042") + }) +}) diff --git a/tests/plaintext-store.test.ts b/tests/plaintext-store.test.ts index 93a3e2d..ece6b24 100644 --- a/tests/plaintext-store.test.ts +++ b/tests/plaintext-store.test.ts @@ -1,9 +1,10 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { afterEach, describe, expect, it } from "vitest" import { GitDbEngine } from "../src/sql/engine.js" import { LocalPlaintextStore } from "../src/storage/local-plaintext-store.js" +import type { VisibleDatabaseSnapshot } from "../src/types.js" describe("plaintext store", () => { const roots: string[] = [] @@ -48,18 +49,118 @@ describe("plaintext store", () => { await first.execute("CREATE TABLE people (id STRING, name STRING)") await first.execute("INSERT INTO people VALUES ('p1', 'Lin')") const schemaPath = join(root, "gitdb/v1/people/schema.json") - const dataPath = join(root, "gitdb/v1/people/data.json") + const dataPath = join(root, "gitdb/v1/people/pages/000000.json") const visibleSchema = await readFile(schemaPath, "utf8") const visibleData = await readFile(dataPath, "utf8") await writeFile(dataPath, visibleData.replace('"Lin"', '"Ada"'), "utf8") const second = await GitDbEngine.open({ store }) const result = await second.execute("SELECT name FROM people WHERE id = 'p1'") - // Then: the visible table file is human-readable and drives the reopened DB state. + // Then: the visible table page is human-readable and drives the reopened DB state. expect(visibleSchema).toContain('"columns"') expect(visibleData).not.toContain('"columns"') expect(visibleData).not.toContain('"people"') expect(visibleData).toContain('"Lin"') expect(result.rows).toEqual([{ name: "Ada" }]) }) + + it("replays logs when visible snapshot checkpoint is stale", async () => { + // Given: a plaintext store with a current visible snapshot and later unsnapshotted log entries. + const root = await mkdtemp(join(tmpdir(), "gitdb-stale-snapshot-")) + roots.push(root) + const first = await GitDbEngine.open({ + snapshotPolicy: { mode: "interval", mutations: 100 }, + store: new LocalPlaintextStore({ root }), + }) + await first.execute("CREATE TABLE events (id STRING, label STRING)") + await first.execute("INSERT INTO events VALUES ('e1', 'snapshotted')") + await first.execute("INSERT INTO events VALUES ('e2', 'log-only')") + + // When: the engine reopens after the manifest moved beyond the visible snapshot checkpoint. + const second = await GitDbEngine.open({ store: new LocalPlaintextStore({ root }) }) + const result = await second.execute("SELECT label FROM events ORDER BY id") + + // Then: it ignores the stale visible files and replays the mutation log. + expect(result.rows).toEqual([{ label: "snapshotted" }, { label: "log-only" }]) + }) + + it("replays logs when visible snapshot has no checkpoint", async () => { + // Given: a manifest-backed store with old dashboard files but no snapshot checkpoint. + const root = await mkdtemp(join(tmpdir(), "gitdb-uncheckpointed-snapshot-")) + roots.push(root) + const engine = await GitDbEngine.open({ + snapshotPolicy: { mode: "interval", mutations: 100 }, + store: new LocalPlaintextStore({ root }), + }) + await engine.execute("CREATE TABLE events (id STRING, label STRING)") + await engine.execute("INSERT INTO events VALUES ('e1', 'dashboard-only')") + await engine.execute("INSERT INTO events VALUES ('e2', 'log-only')") + await mkdir(join(root, "gitdb/v1/events"), { recursive: true }) + await writeFile( + join(root, "gitdb/v1/events/schema.json"), + '{ "name": "events", "columns": ["id", "label"] }', + "utf8", + ) + await writeFile( + join(root, "gitdb/v1/events/data.json"), + '[{ "id": "e1", "label": "dashboard-only" }]', + "utf8", + ) + + // When: the engine reopens after seeing uncheckpointed visible files. + const reopened = await GitDbEngine.open({ store: new LocalPlaintextStore({ root }) }) + const result = await reopened.execute("SELECT label FROM events ORDER BY id") + + // Then: manifest/log replay wins over the uncheckpointed dashboard snapshot. + expect(result.rows).toEqual([{ label: "dashboard-only" }, { label: "log-only" }]) + }) + + it("writes visible snapshot checkpoint after table files", async () => { + // Given: a table path that will fail because it is already a file. + const root = await mkdtemp(join(tmpdir(), "gitdb-snapshot-order-")) + roots.push(root) + const store = new LocalPlaintextStore({ root }) + await mkdir(join(root, "gitdb/v1"), { recursive: true }) + await writeFile(join(root, "gitdb/v1/events"), "not a directory", "utf8") + + // When: the visible snapshot write fails before table contents are complete. + await expect( + store.writeVisibleSnapshot({ + sequence: 1, + tables: [{ columns: ["id"], name: "events", rows: [{ id: "e1" }] }], + }), + ).rejects.toThrow() + + // Then: the advanced checkpoint is not left behind. + await expect(readFile(join(root, "gitdb/v1/snapshot.json"), "utf8")).rejects.toThrow() + }) + + it("throttles visible snapshot writes for local plaintext speed", async () => { + // Given: a plaintext store that counts visible snapshot writes. + const root = await mkdtemp(join(tmpdir(), "gitdb-snapshot-policy-")) + roots.push(root) + const store = new CountingPlaintextStore({ root }) + const engine = await GitDbEngine.open({ + snapshotPolicy: { mode: "interval", mutations: 100 }, + store, + }) + + // When: many local mutations run against the same table. + await engine.execute("CREATE TABLE events (id STRING, label STRING)") + for (let index = 0; index < 100; index += 1) { + await engine.execute(`INSERT INTO events VALUES ('e${index}', 'event ${index}')`) + } + + // Then: visible dashboard snapshots are checkpointed instead of rewritten per row. + expect(store.snapshotWrites).toBeLessThanOrEqual(3) + }) }) + +class CountingPlaintextStore extends LocalPlaintextStore { + snapshotWrites = 0 + + override async writeVisibleSnapshot(snapshot: VisibleDatabaseSnapshot): Promise { + this.snapshotWrites += 1 + await super.writeVisibleSnapshot(snapshot) + } +} diff --git a/tests/site.test.ts b/tests/site.test.ts new file mode 100644 index 0000000..7f90062 --- /dev/null +++ b/tests/site.test.ts @@ -0,0 +1,73 @@ +import { readFile } from "node:fs/promises" +import { describe, expect, it } from "vitest" + +type BenchmarkEvidence = { + readonly comparisons?: readonly unknown[] + readonly current?: { + readonly metadata?: unknown + } + readonly scenarios: readonly BenchmarkScenario[] +} + +type BenchmarkScenario = { + readonly key: string +} + +describe("website", () => { + it("publishes benchmark evidence consumed by the static site", async () => { + const parsed: unknown = JSON.parse(await readFile("site/benchmark.json", "utf8")) + + expect(isBenchmarkEvidence(parsed)).toBe(true) + if (!isBenchmarkEvidence(parsed)) { + throw new Error("invalid benchmark evidence") + } + expect(parsed.scenarios.map((scenario) => scenario.key)).toEqual( + expect.arrayContaining([ + "local-plaintext", + "local-encrypted", + "orm-save-single", + "orm-insert-many", + "orm-save-many", + "orm-indexed-lookup", + "reopen", + "compaction", + ]), + ) + expect(parsed.scenarios.length).toBeGreaterThanOrEqual(8) + expect(parsed.current?.metadata).toBeDefined() + }) + + it("deploys the site directory through GitHub Pages", async () => { + const workflow = await readFile(".github/workflows/pages.yml", "utf8") + const index = await readFile("site/index.html", "utf8") + const app = await readFile("site/app.js", "utf8") + + expect(workflow).toContain("path: site") + expect(app).toContain("./benchmark.json") + expect(app).toContain("orm-indexed-lookup") + expect(index).toContain("GitHub Repo + DB Runtime") + expect(index).toContain("Guarantee Matrix") + expect(index).toContain("Transactions") + expect(index).toContain("Compaction") + expect(index).toContain("./assets/runtime-map.svg") + expect(index).toContain("docs/API.md") + expect(index).toContain("docs/RELEASE.md") + expect(index).not.toMatch(/PostgreSQL|Prisma|facade|postgresql:\/\//i) + }) +}) + +function isBenchmarkEvidence(value: unknown): value is BenchmarkEvidence { + return ( + typeof value === "object" && + value !== null && + "scenarios" in value && + Array.isArray(value.scenarios) && + value.scenarios.every(isBenchmarkScenario) + ) +} + +function isBenchmarkScenario(value: unknown): value is BenchmarkScenario { + return ( + typeof value === "object" && value !== null && "key" in value && typeof value.key === "string" + ) +} diff --git a/tests/sql-engine-fixtures.ts b/tests/sql-engine-fixtures.ts new file mode 100644 index 0000000..7c84f19 --- /dev/null +++ b/tests/sql-engine-fixtures.ts @@ -0,0 +1,62 @@ +import { + commitBatchWithV1Store, + type GitDbCommitBatch, + type GitDbCommitResult, + type GitDbStore, + storeCapabilities, +} from "../src/storage/store.js" +import type { + GitDbManifest, + PersistedMutation, + SegmentId, + VisibleDatabaseSnapshot, +} from "../src/types.js" + +export class CountingBatchStore implements GitDbStore { + readonly capabilities = storeCapabilities({ atomicLocal: true }) + readonly #delegate: GitDbStore + appendMutationCalls = 0 + commitBatchCalls = 0 + writeManifestCalls = 0 + + constructor(delegate: GitDbStore) { + this.#delegate = delegate + } + + resetCounts(): void { + this.appendMutationCalls = 0 + this.commitBatchCalls = 0 + this.writeManifestCalls = 0 + } + + async readManifest(): Promise { + return await this.#delegate.readManifest() + } + + async writeManifest(manifest: GitDbManifest): Promise { + this.writeManifestCalls += 1 + await this.#delegate.writeManifest(manifest) + } + + async appendMutation(mutation: PersistedMutation): Promise { + this.appendMutationCalls += 1 + return await this.#delegate.appendMutation(mutation) + } + + async readMutations(segments: readonly SegmentId[]): Promise { + return await this.#delegate.readMutations(segments) + } + + async readVisibleSnapshot(): Promise { + return (await this.#delegate.readVisibleSnapshot?.()) ?? null + } + + async writeVisibleSnapshot(snapshot: VisibleDatabaseSnapshot): Promise { + await this.#delegate.writeVisibleSnapshot?.(snapshot) + } + + async commitBatch(batch: GitDbCommitBatch): Promise { + this.commitBatchCalls += 1 + return await commitBatchWithV1Store(this, batch) + } +} diff --git a/tests/sql-engine.test.ts b/tests/sql-engine.test.ts index 6019da3..5bdd83a 100644 --- a/tests/sql-engine.test.ts +++ b/tests/sql-engine.test.ts @@ -1,10 +1,19 @@ -import { mkdtemp, readFile } from "node:fs/promises" +import { mkdtemp, readdir, readFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { describe, expect, it } from "vitest" import { createAesGcmCipher } from "../src/crypto/aes-gcm.js" import { GitDbEngine } from "../src/sql/engine.js" import { LocalEncryptedStore } from "../src/storage/local-encrypted-store.js" +import { LocalPlaintextStore } from "../src/storage/local-plaintext-store.js" +import type { GitDbStore } from "../src/storage/store.js" +import type { + GitDbManifest, + PersistedMutation, + SegmentId, + VisibleDatabaseSnapshot, +} from "../src/types.js" +import { CountingBatchStore } from "./sql-engine-fixtures.js" describe("GitDbEngine", () => { it("executes join and aggregate queries while persisting encrypted GitDB segments", async () => { @@ -13,6 +22,7 @@ describe("GitDbEngine", () => { const key = Buffer.alloc(32, 7).toString("base64url") const store = new LocalEncryptedStore({ root, cipher: createAesGcmCipher(key) }) const engine = await GitDbEngine.open({ store }) + expect(engine.getDurabilityMode()).toBe("sync") // When: schema and rows are written, then queried with advanced SQL. await engine.execute("CREATE TABLE users (id STRING, name STRING, org_id STRING)") @@ -55,4 +65,221 @@ describe("GitDbEngine", () => { // Then: state is rebuilt from GitDB files, not process memory. expect(result.rows).toEqual([{ label: "release" }]) }) + + it("commits transactions atomically and rolls back failed statements without log drift", async () => { + // Given: a table with one committed row and a durable encrypted mutation log. + const root = await mkdtemp(join(tmpdir(), "gitdb-transaction-")) + const key = Buffer.alloc(32, 11).toString("base64url") + const store = new LocalEncryptedStore({ root, cipher: createAesGcmCipher(key) }) + const engine = await GitDbEngine.open({ store }) + await engine.execute("CREATE TABLE accounts (id STRING, balance INT)") + await engine.execute("INSERT INTO accounts VALUES ('a1', 10)") + + // When: one transaction succeeds and a later transaction fails midway. + await engine.transaction(async (transaction) => { + await transaction.execute("INSERT INTO accounts VALUES ('a2', 20)") + await transaction.execute("INSERT INTO accounts VALUES ('a3', 30)") + }) + const logAfterCommit = await readdir(join(root, "gitdb", "v1", "log")) + await expect( + engine.transaction(async (transaction) => { + await transaction.execute("INSERT INTO accounts VALUES ('a4', 40)") + await transaction.execute("THIS IS NOT SQL") + }), + ).rejects.toThrow("SQL failed") + + // Then: the failed transaction is not visible and did not append partial WAL segments. + const logAfterRollback = await readdir(join(root, "gitdb", "v1", "log")) + const rows = await engine.execute("SELECT id, balance FROM accounts ORDER BY id") + expect(rows.rows).toEqual([ + { balance: 10, id: "a1" }, + { balance: 20, id: "a2" }, + { balance: 30, id: "a3" }, + ]) + expect(logAfterRollback).toHaveLength(logAfterCommit.length) + }) + + it("persists multi-statement transactions through one store batch commit", async () => { + // Given: a counting v2 store and a prepared table. + const root = await mkdtemp(join(tmpdir(), "gitdb-engine-batch-")) + const store = new CountingBatchStore(new LocalPlaintextStore({ root })) + const engine = await GitDbEngine.open({ + durability: "local", + snapshotPolicy: { mode: "interval", mutations: 1000 }, + store, + }) + await engine.execute("CREATE TABLE events (id INT, label STRING)") + store.resetCounts() + + // When: 250 inserts are committed inside one transaction. + await engine.transaction(async (transaction) => { + for (let id = 0; id < 250; id += 1) { + await transaction.execute(`INSERT INTO events VALUES (${id}, 'event-${id}')`) + } + }) + + // Then: the engine uses one store batch boundary and does not rewrite manifest per row. + const rows = await engine.execute("SELECT COUNT(*) AS event_count FROM events") + expect(rows.rows).toEqual([{ event_count: 250 }]) + expect(store.commitBatchCalls).toBe(1) + expect(store.writeManifestCalls).toBeLessThanOrEqual(3) + }) + + it("does not publish orphan segments after a manifest write failure", async () => { + // Given: a plaintext store that fails the next manifest write after appending a segment. + const root = await mkdtemp(join(tmpdir(), "gitdb-manifest-failure-")) + const store = new FailingManifestStore(new LocalPlaintextStore({ root })) + const engine = await GitDbEngine.open({ + snapshotPolicy: { mode: "interval", mutations: 100 }, + store, + }) + await engine.execute("CREATE TABLE accounts (id STRING, balance INT)") + await engine.execute("INSERT INTO accounts VALUES ('a1', 10)") + + // When: persistence fails after a transaction append, then a later write succeeds. + store.failNextManifestWrite() + await expect( + engine.transaction(async (transaction) => { + await transaction.execute("INSERT INTO accounts VALUES ('a2', 20)") + }), + ).rejects.toThrow("manifest failed") + await engine.execute("INSERT INTO accounts VALUES ('a3', 30)") + const reopened = await GitDbEngine.open({ store: new LocalPlaintextStore({ root }) }) + const rows = await reopened.execute("SELECT id, balance FROM accounts ORDER BY id") + + // Then: the failed orphan segment is not pulled into a later manifest. + expect(rows.rows).toEqual([ + { balance: 10, id: "a1" }, + { balance: 30, id: "a3" }, + ]) + }) + + it("restores committed state when a single-statement manifest write fails", async () => { + // Given: a plaintext store that fails manifest persistence after appending a segment. + const root = await mkdtemp(join(tmpdir(), "gitdb-fast-manifest-failure-")) + const store = new FailingManifestStore(new LocalPlaintextStore({ root })) + const engine = await GitDbEngine.open({ + snapshotPolicy: { mode: "interval", mutations: 100 }, + store, + }) + await engine.execute("CREATE TABLE accounts (id STRING, balance INT)") + await engine.execute("INSERT INTO accounts VALUES ('a1', 10)") + + // When: the single-statement fast path fails after mutating memory. + store.failNextManifestWrite() + await expect(engine.execute("INSERT INTO accounts VALUES ('a2', 20)")).rejects.toThrow( + "manifest failed", + ) + const liveRows = await engine.execute("SELECT id, balance FROM accounts ORDER BY id") + const reopened = await GitDbEngine.open({ store: new LocalPlaintextStore({ root }) }) + const reopenedRows = await reopened.execute("SELECT id, balance FROM accounts ORDER BY id") + + // Then: live memory and reopened storage both reflect only committed manifest state. + expect(liveRows.rows).toEqual([{ balance: 10, id: "a1" }]) + expect(reopenedRows.rows).toEqual([{ balance: 10, id: "a1" }]) + }) + + it("keeps committed mutations successful when visible snapshot refresh fails", async () => { + // Given: a plaintext store whose derived visible snapshot can fail independently. + const root = await mkdtemp(join(tmpdir(), "gitdb-snapshot-failure-")) + const store = new FailingSnapshotStore(new LocalPlaintextStore({ root })) + const engine = await GitDbEngine.open({ store }) + await engine.execute("CREATE TABLE accounts (id STRING, balance INT)") + + // When: the manifest/log commit succeeds but the post-commit checkpoint write fails. + store.failNextSnapshotWrite() + await expect(engine.execute("INSERT INTO accounts VALUES ('a1', 10)")).resolves.toMatchObject({ + rowCount: 1, + }) + const liveRows = await engine.execute("SELECT id, balance FROM accounts") + const reopened = await GitDbEngine.open({ store: new LocalPlaintextStore({ root }) }) + const replayedRows = await reopened.execute("SELECT id, balance FROM accounts") + + // Then: the committed row remains visible and recoverable from the mutation log. + expect(engine.getLastVisibleSnapshotError()).toBeInstanceOf(Error) + expect(liveRows.rows).toEqual([{ balance: 10, id: "a1" }]) + expect(replayedRows.rows).toEqual([{ balance: 10, id: "a1" }]) + }) }) + +class FailingManifestStore implements GitDbStore { + readonly #delegate: GitDbStore + #failNextManifestWrite = false + + constructor(delegate: GitDbStore) { + this.#delegate = delegate + } + + failNextManifestWrite(): void { + this.#failNextManifestWrite = true + } + + async readManifest(): Promise { + return await this.#delegate.readManifest() + } + + async writeManifest(manifest: GitDbManifest): Promise { + if (this.#failNextManifestWrite) { + this.#failNextManifestWrite = false + throw new Error("manifest failed") + } + await this.#delegate.writeManifest(manifest) + } + + async appendMutation(mutation: PersistedMutation): Promise { + return await this.#delegate.appendMutation(mutation) + } + + async readMutations(segments: readonly SegmentId[]): Promise { + return await this.#delegate.readMutations(segments) + } + + async readVisibleSnapshot(): Promise { + return (await this.#delegate.readVisibleSnapshot?.()) ?? null + } + + async writeVisibleSnapshot(snapshot: VisibleDatabaseSnapshot): Promise { + await this.#delegate.writeVisibleSnapshot?.(snapshot) + } +} + +class FailingSnapshotStore implements GitDbStore { + readonly #delegate: GitDbStore + #failNextSnapshotWrite = false + + constructor(delegate: GitDbStore) { + this.#delegate = delegate + } + + failNextSnapshotWrite(): void { + this.#failNextSnapshotWrite = true + } + + async readManifest(): Promise { + return await this.#delegate.readManifest() + } + + async writeManifest(manifest: GitDbManifest): Promise { + await this.#delegate.writeManifest(manifest) + } + + async appendMutation(mutation: PersistedMutation): Promise { + return await this.#delegate.appendMutation(mutation) + } + + async readMutations(segments: readonly SegmentId[]): Promise { + return await this.#delegate.readMutations(segments) + } + + async readVisibleSnapshot(): Promise { + return (await this.#delegate.readVisibleSnapshot?.()) ?? null + } + + async writeVisibleSnapshot(snapshot: VisibleDatabaseSnapshot): Promise { + if (this.#failNextSnapshotWrite) { + this.#failNextSnapshotWrite = false + throw new Error("snapshot failed") + } + await this.#delegate.writeVisibleSnapshot?.(snapshot) + } +} diff --git a/tests/sql-normalize.test.ts b/tests/sql-normalize.test.ts new file mode 100644 index 0000000..a8be21e --- /dev/null +++ b/tests/sql-normalize.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest" +import { + commandTag, + isMutation, + isTransactionControl, + normalizeGitDbSql, +} from "../src/sql/normalize.js" + +describe("SQL normalization", () => { + it("maps common PostgreSQL-style column types to the local engine dialect", () => { + // Given: user SQL copied from a PostgreSQL-flavored schema. + const sql = + 'CREATE TABLE "events" (id SERIAL, title VARCHAR(255), active BOOLEAN, at TIMESTAMPTZ, payload JSONB)' + + // When: GitDB normalizes the statement for its local engine. + const normalized = normalizeGitDbSql(sql) + + // Then: dialect-specific types become the engine's supported types. + expect(normalized).toBe( + "CREATE TABLE events (id INT, title STRING, active BOOL, at STRING, payload JSON)", + ) + }) + + it("returns command tags for mutation and transaction statements", () => { + // Given: SQL statements have already executed with known row counts. + const statements = [ + commandTag("insert into people values ('p1')", 1), + commandTag("update people set name = 'Ada'", 3), + commandTag("rollback", 0), + ] + + // When/Then: command tags follow the familiar database response shape. + expect(statements).toEqual(["INSERT 0 1", "UPDATE 3", "ROLLBACK"]) + }) + + it("classifies mutations and transaction controls separately", () => { + // Given: the engine decides persistence from statement class. + const createTable = "CREATE TABLE people (id STRING)" + const begin = "BEGIN" + const select = "SELECT * FROM people" + + // When/Then: mutation and transaction controls do not overlap. + expect(isMutation(createTable)).toBe(true) + expect(isTransactionControl(begin)).toBe(true) + expect(isMutation(select)).toBe(false) + expect(isTransactionControl(createTable)).toBe(false) + }) +}) diff --git a/tests/sql-rows.test.ts b/tests/sql-rows.test.ts new file mode 100644 index 0000000..cce1909 --- /dev/null +++ b/tests/sql-rows.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest" +import { primitiveToWire, sqlLiteral, toRows } from "../src/sql/rows.js" + +describe("SQL row helpers", () => { + it("parses engine rows into primitive SQL rows", () => { + // Given: alasql returns an array of primitive row objects. + const value = [ + { active: true, id: "p1", score: 7 }, + { active: false, id: "p2", score: null }, + ] + + // When: GitDB converts the unknown engine response. + const rows = toRows(value) + + // Then: the public row shape is preserved. + expect(rows).toEqual(value) + }) + + it("rejects nested row values that cannot be represented as SQL primitives", () => { + // Given: an engine response contains a nested object value. + const value = [{ id: "p1", metadata: { nested: true } }] + + // When/Then: row parsing fails at the boundary. + expect(() => toRows(value)).toThrow() + }) + + it("formats wire values and SQL literals without leaking invalid numbers", () => { + // Given: values move through CLI wire output and SQL statement generation. + const values = [ + primitiveToWire(null), + primitiveToWire(true), + sqlLiteral("Ada's team"), + sqlLiteral(Number.NaN), + ] + + // When/Then: nulls, booleans, quotes, and invalid numbers use safe representations. + expect(values).toEqual([null, "true", "'Ada''s team'", "NULL"]) + }) +}) diff --git a/tests/store-contract-fixtures.ts b/tests/store-contract-fixtures.ts new file mode 100644 index 0000000..7c83dfd --- /dev/null +++ b/tests/store-contract-fixtures.ts @@ -0,0 +1,166 @@ +import { mkdtemp } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createAesGcmCipher } from "../src/crypto/aes-gcm.js" +import { GitHubEncryptedStore } from "../src/github/github-encrypted-store.js" +import { GitHubPlaintextStore } from "../src/github/github-plaintext-store.js" +import type { GitHubConfig } from "../src/github/types.js" +import { + type GitDbCommitBatch, + type GitDbLock, + type GitDbLockInput, + type GitDbStore, + type GitDbStoreCapabilities, + LocalEncryptedStore, + LocalPlaintextStore, +} from "../src/index.js" +import type { GitDbManifest, PersistedMutation, SegmentId } from "../src/types.js" +import { segmentId } from "../src/types.js" + +const githubConfig = { + branch: "main", + owner: "3x-haust", + prefix: "gitdb/v1", + repo: "gitdb-example-db", + token: "redacted-token", +} satisfies GitHubConfig + +export const mutationA: PersistedMutation = { + at: "2026-06-14T00:00:00.000Z", + sequence: 1, + sql: "CREATE TABLE people (id STRING, name STRING)", +} + +export const mutationB: PersistedMutation = { + at: "2026-06-14T00:00:01.000Z", + sequence: 2, + sql: "INSERT INTO people VALUES ('p1', 'Lin')", +} + +export const manifest: GitDbManifest = { + createdAt: mutationA.at, + logSegments: [segmentId("00000000000000000001"), segmentId("00000000000000000002")], + sequence: 2, + updatedAt: mutationB.at, + version: 1, +} + +type StoreCase = { + readonly expectedCapabilities: GitDbStoreCapabilities + readonly name: string + readonly reopen?: () => GitDbStore + readonly store: GitDbStore +} + +export async function localStoreCases(roots: string[]): Promise { + const plaintextRoot = await mkdtemp(join(tmpdir(), "gitdb-store-contract-plain-")) + const encryptedRoot = await mkdtemp(join(tmpdir(), "gitdb-store-contract-enc-")) + const encryptedKey = Buffer.alloc(32, 19).toString("base64url") + roots.push(plaintextRoot, encryptedRoot) + return [ + { + expectedCapabilities: { + atomicLocal: true, + compaction: true, + encrypted: false, + locks: true, + remote: false, + treeCommits: false, + visibleSnapshots: true, + }, + name: "local plaintext", + reopen: () => new LocalPlaintextStore({ root: plaintextRoot }), + store: new LocalPlaintextStore({ root: plaintextRoot }), + }, + { + expectedCapabilities: { + atomicLocal: true, + compaction: false, + encrypted: true, + locks: true, + remote: false, + treeCommits: false, + visibleSnapshots: false, + }, + name: "local encrypted", + reopen: () => + new LocalEncryptedStore({ + cipher: createAesGcmCipher(encryptedKey), + root: encryptedRoot, + }), + store: new LocalEncryptedStore({ + cipher: createAesGcmCipher(encryptedKey), + root: encryptedRoot, + }), + }, + ] +} + +export function githubStoreCases(): readonly StoreCase[] { + return [ + { + expectedCapabilities: { + atomicLocal: false, + compaction: false, + encrypted: false, + locks: false, + remote: true, + treeCommits: true, + visibleSnapshots: true, + }, + name: "github plaintext", + store: new GitHubPlaintextStore(githubConfig), + }, + { + expectedCapabilities: { + atomicLocal: false, + compaction: false, + encrypted: true, + locks: false, + remote: true, + treeCommits: true, + visibleSnapshots: false, + }, + name: "github encrypted", + store: new GitHubEncryptedStore( + githubConfig, + createAesGcmCipher(Buffer.alloc(32, 23).toString("base64url")), + ), + }, + ] +} + +export function commitBatch(): GitDbCommitBatch { + return { + manifest, + mutations: [mutationA, mutationB], + } +} + +export async function acquireLock(store: GitDbStore, input: GitDbLockInput): Promise { + if (store.acquireLock === undefined) { + throw new Error("store does not expose acquireLock") + } + return await store.acquireLock(input) +} + +export class RecordingStore implements GitDbStore { + readonly events: string[] = [] + + async readManifest(): Promise { + return null + } + + async writeManifest(nextManifest: GitDbManifest): Promise { + this.events.push(`manifest:${nextManifest.sequence}`) + } + + async appendMutation(mutation: PersistedMutation): Promise { + this.events.push(`append:${mutation.sequence}`) + return segmentId(mutation.sequence.toString().padStart(20, "0")) + } + + async readMutations(): Promise { + return [] + } +} diff --git a/tests/store-contract.test.ts b/tests/store-contract.test.ts new file mode 100644 index 0000000..7eb2be3 --- /dev/null +++ b/tests/store-contract.test.ts @@ -0,0 +1,135 @@ +import { rm } from "node:fs/promises" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { commitBatchThroughStore } from "../src/index.js" +import { + acquireLock, + commitBatch, + githubStoreCases, + localStoreCases, + manifest, + mutationA, + mutationB, + RecordingStore, +} from "./store-contract-fixtures.js" + +const octokit = vi.hoisted(() => ({ + createOrUpdateFileContents: vi.fn(), + getContent: vi.fn(), +})) + +vi.mock("@octokit/rest", () => ({ + Octokit: vi.fn(function MockOctokit() { + return { + repos: { + createOrUpdateFileContents: octokit.createOrUpdateFileContents, + getContent: octokit.getContent, + }, + } + }), +})) + +describe("GitDbStore v2 contract", () => { + const roots: string[] = [] + + beforeEach(() => { + octokit.createOrUpdateFileContents.mockReset() + octokit.getContent.mockReset() + }) + + afterEach(async () => { + // Given: store contract tests allocate local roots. + const pending = roots.splice(0, roots.length) + + // When: a scenario completes. + await Promise.all(pending.map((root) => rm(root, { force: true, recursive: true }))) + + // Then: no local store state leaks across contract tests. + expect(roots).toHaveLength(0) + }) + + it("commits a batch atomically through the v2 store contract", async () => { + // Given: v1 local stores have only appendMutation and writeManifest. + const stores = await localStoreCases(roots) + + for (const { name, reopen, store } of stores) { + // When: callers use the additive v2 batch adapter. + const result = await commitBatchThroughStore(store, commitBatch()) + const recovered = reopen?.() ?? store + + // Then: the batch is durable through the existing v1 read APIs. + await expect(recovered.readManifest(), name).resolves.toEqual(manifest) + await expect(recovered.readMutations(result.segments), name).resolves.toEqual([ + mutationA, + mutationB, + ]) + expect(result).toEqual({ + manifest, + segments: manifest.logSegments, + }) + } + }) + + it("exposes capabilities for local and GitHub store implementations", async () => { + // Given: every store advertises what guarantees it currently owns. + const stores = [...(await localStoreCases(roots)), ...githubStoreCases()] + + // When/Then: capability keys are explicit and conservative. + for (const { expectedCapabilities, store } of stores) { + expect(store.capabilities).toEqual(expectedCapabilities) + } + }) + + it("adapts v1 append and manifest methods without requiring store rewrites", async () => { + // Given: a legacy store implements only the original v1 methods. + const store = new RecordingStore() + + // When: a batch commit runs through the compatibility adapter. + const result = await commitBatchThroughStore(store, commitBatch()) + + // Then: mutations are appended before the manifest boundary is advanced. + expect(result.segments).toEqual(manifest.logSegments) + expect(store.events).toEqual(["append:1", "append:2", "manifest:2"]) + }) + + it("local stores commit, reject stale CAS, and recover stale writer locks", async () => { + // Given: local v2 stores use native batch, CAS, and lock methods. + const stores = await localStoreCases(roots) + + for (const { name, reopen, store } of stores) { + const result = await commitBatchThroughStore(store, { + ...commitBatch(), + compareAndSwap: { expectedSequence: null }, + }) + const persistedStore = reopen?.() ?? store + + await expect(persistedStore.readManifest(), name).resolves.toEqual(manifest) + await expect(persistedStore.readMutations(result.segments), name).resolves.toEqual([ + mutationA, + mutationB, + ]) + + // When/Then: stale CAS prevents manifest advancement. + await expect( + commitBatchThroughStore(store, { + compareAndSwap: { expectedSequence: 1 }, + manifest: { ...manifest, sequence: 3 }, + mutations: [{ ...mutationB, sequence: 3 }], + }), + name, + ).rejects.toThrow("manifest compare-and-swap failed") + + // When/Then: the writer lock rejects active contenders and recovers stale locks. + const active = await acquireLock(store, { staleAfterMs: 30_000, timeoutMs: 0 }) + await expect( + acquireLock(store, { staleAfterMs: 30_000, timeoutMs: 0 }), + name, + ).rejects.toThrow("local store lock is held") + await active.release() + const stale = await acquireLock(store, { staleAfterMs: 30_000, timeoutMs: 0 }) + const recoveredLock = await acquireLock(store, { staleAfterMs: 0, timeoutMs: 0 }) + expect(recoveredLock.token).not.toBe(stale.token) + await recoveredLock.release() + await stale.release() + } + }) +})