Skip to content

Devansh-365/sheetforge

Repository files navigation


sheetforge

sheetforge

Google Sheets as a backend that actually behaves like one.

Serialized writes. Idempotent retries. Typed TypeScript SDKs, generated live from your sheet's headers.


CI Stars Issues License Hosted SaaS PRs welcome


Get started free   How it works


🟢 Hosted SaaS is live

getsheetforge.vercel.app — one-click Google sign-in, no infra required. The MIT-licensed OSS core (packages/queue, packages/codegen, packages/sdk-ts) stays free forever, and self-hosting is fully supported if you'd rather run your own stack. See Quickstart below for both paths.

TL;DR. The Google Sheets API drops rows under concurrent writes. Every "Sheets as a backend" wrapper (SheetDB, Sheety, NoCodeAPI) forwards your POST straight to values.append and inherits the bug. sheetforge wraps every write in a per-sheet queue fenced by a Postgres advisory lock: 50 parallel writes, 50 ordered rows, retry-safe by key. And because your header row is the schema, you get a typed TypeScript SDK you can commit alongside the call sites.

The problem

Raw Google Sheets API

await Promise.all([
  sheets.values.append({ ...a }),
  sheets.values.append({ ...b }),
  sheets.values.append({ ...c }),
  sheets.values.append({ ...d }),
]);

// 4 POSTs, 3 rows.
// one silently clobbered.

sheetforge

await Promise.all([
  sheetforge.rows.insert(a, { idempotencyKey: '1' }),
  sheetforge.rows.insert(b, { idempotencyKey: '2' }),
  sheetforge.rows.insert(c, { idempotencyKey: '3' }),
  sheetforge.rows.insert(d, { idempotencyKey: '4' }),
]);

// 4 POSTs, 4 rows, in order.
// retry-safe by key.

values.append isn't serializable. Two concurrent appends can resolve to the same target row and one silently overwrites the other. It's a documented Google bug whose upstream workaround boils down to "don't write concurrently." Fine for a demo. Not fine when your signup form catches an HN spike.

Status

Surface Status
Hosted SaaS (sign up, skip local infra) ✅ live at getsheetforge.vercel.app
Dashboard + API (write-queue, SDK codegen) ✅ live (also runs locally via pnpm dev)
Self-host ✅ supported — see quickstart
OSS core (packages/queue, packages/codegen, packages/sdk-ts) ✅ MIT, free forever

Quickstart

Hosted (30 seconds)

  1. Go to getsheetforge.vercel.app
  2. Sign in with Google
  3. Connect a Sheet

Self-host

git clone https://github.com/Devansh-365/sheetforge.git
cd sheetforge
pnpm install
cp .env.example .env          # fill in Google OAuth + DATABASE_URL
pnpm db:push                  # apply migrations (works against Neon too)
pnpm dev                      # web :3000, api + processor :3001

Prereqs. Node 20+, pnpm 9+, Postgres 14+, and either local Redis on :6379 or an Upstash REST endpoint (UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN).

What you do in the dashboard
  1. Open http://localhost:3000 and click Dashboard. Google OAuth consent with the Sheets scope.
  2. Land on /app. Click + new project, then + create key, then copy the sk_live_… value (shown once).
  3. Click + connect sheet. Paste a Google Sheets URL. Pick a tab. The header row becomes your schema.
  4. On the sheet page, click ↓ download client.ts. That is your typed SDK.
.env.example (full list)
PORT=3001
PUBLIC_BASE_URL=http://localhost:3001
WEB_BASE_URL=http://localhost:3000
ALLOWED_WEB_ORIGINS=                   # optional, commas. e.g. preview deploys
DATABASE_URL=postgres://…              # local or Neon
REDIS_URL=redis://localhost:6379       # either this…
UPSTASH_REDIS_REST_URL=                # …or these two
UPSTASH_REDIS_REST_TOKEN=
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_REDIRECT_URL=http://localhost:3001/v1/oauth/callback
SESSION_JWT_SECRET=                    # openssl rand -hex 32
PROCESSOR_ENABLED=true
PROCESSOR_TICK_MS=1000

First write

REST

curl -X POST \
  http://localhost:3001/v1/sheets/$SHEET_ID/rows \
  -H "Authorization: Bearer $API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"email":"hi@example.com","plan":"free"}'

Returns { writeId, status: 'pending' }. The row lands in the sheet in about one second.

Typed SDK (generated from your headers)

import { createClient } from './sheetforge-client';

const sheet = createClient({
  apiKey: process.env.SHEETFORGE_API_KEY!,
  sheetId: 'sht_…',
});

await sheet.rows.insert(
  { email: 'hi@example.com', plan: 'free' },
  { idempotencyKey: crypto.randomUUID() },
);

plan: 'free' | 'pro' because the sample cells read 'free' and 'pro'.

Features

Serialized per-sheet queue

One worker per sheet at a time. Ordering guaranteed by a Postgres advisory lock, not a token protocol or a lease clock.

Idempotency by design

Every endpoint accepts Idempotency-Key. Retries dedupe via a partial unique index. Network flakes never double-write.

Typed SDK from live schema

Point at a sheet, get a typed TypeScript client. Literal unions inferred from sample cells. The compiler catches drift.

No polling

Redis Streams with consumer groups. The processor blocks on XREADGROUP and acks only after the handler commits.

Crash-safe by construction

Handler runs inside a Postgres transaction. XACK happens post-commit. Mid-flight crash rolls back. The PEL redelivers. The idempotency key catches the replay.

Self-host in 60 seconds

No Docker required for local dev — one Postgres URL, one Redis URL (or Upstash REST). Next.js and Hono, both in one pnpm dev. Or skip all of it and use the hosted version.

How the queue actually works

submitWrite() is the only ingress. It inserts a ledger row in Postgres (with a partial unique index on (sheet_id, idempotency_key) so retries dedupe), then XADDs an envelope to the sheet's Redis stream. The processor runs processNext() in a loop: XREADGROUP on the consumer group, take a Postgres advisory lock keyed by hashtextextended(streamKey, 0) inside a transaction, run the handler, XACK only after commit. A crash mid-handler rolls the transaction back, the PEL redelivers, and the idempotency key catches the replay. The lock is the fence. No token protocol. No lease clock skew. No leader election.

Touching slices/write-queue/ or packages/queue/? Every change lands with a concurrency test. That is the rule I do not break.

Tech stack

TypeScript Node.js 20 Next.js React 19 Hono Tailwind

PostgreSQL Redis Streams Drizzle Zod Upstash Google Sheets API

pnpm Turborepo Biome Vitest Playwright Cloudflare Workers

How it compares

sheetforge SheetDB Sheety Raw Sheets API
Concurrent-write safety yes, always no no no
Idempotent retries yes no no no
Typed SDK from live schema TypeScript (V0) none none none
Open source yes (MIT packages) no no N/A
Self-host yes no no N/A
Webhooks on write V2 yes no no
Free local dev yes trial trial yes
Python SDK V1 no no no

If you need webhooks today, use SheetDB. If you need your rows to land, come back.

Repo layout

apps/
  web/              Next.js 15 dashboard and marketing
  api/              Hono on @hono/node-server plus inline processor
  worker/           dedicated queue consumer (V1)

slices/             feature slices. barrel is the only public API
  auth/             Google OAuth, sessions, refresh tokens
  projects/         project and API-key CRUD
  sheets/           sheet connect, schema infer, cached reads
  schema/           header row to Zod schema derivation
  write-queue/      submitWrite, processNext, advisory-lock fencing
  rest-api/         Hono routes, CORS, idempotency, error boundary
  sdk-codegen/      schema to TypeScript client
  demo/             local-only hammer demo

packages/           MIT, npm-publishable, no shared/ imports
  queue/            generic Redis-Streams consumer
  codegen/          schema to typed-SDK engine
  sdk-ts/           runtime for generated clients

shared/             infra clients, no business logic
  db/               Drizzle schema and migrations
  redis/            ioredis and Upstash REST adapters
  google/           Sheets client and A1 quoting
  logger/           pino
  types/            typed DomainError hierarchy

Slice internals stay private. Cross-slice imports fail CI. packages/* stays OSS-safe with no shared/* imports, no secrets, publishable to npm tomorrow.

Roadmap

V0, shipping now

  • Google OAuth and session management
  • Sheet connect with header-row schema inference
  • Per-sheet write queue (Redis Streams + pg advisory lock)
  • Idempotency by key, partial unique index
  • Upstash REST adapter (Workers-ready)
  • TypeScript SDK download

V1

  • Hammer demo on the hosted landing page
  • Dedicated worker process (currently inline with API)
  • Python SDK generator
  • Write-status webhooks
  • Per-API-key rate limits
  • Richer cell types (dates, enums from data validation)

V2

  • Sub-10ms cached reads via stream-tailing CDC
  • Per-cell audit log
  • Embeddable signup-form widget
  • Team workspaces
  • Cloudflare Workers deploy target

See the good-first-queue-hack and sdk-codegen labels if you want to help pull V1 in.

Why star this

Most "Sheets as a backend" tools wrap a broken primitive. sheetforge fixes the primitive, then hands you a typed client on the way out. If you've ever shipped a form on Sheets and watched rows vanish mid-launch, a star is the cheapest vote for correctness you can cast.

Contributing

Before sending a PR:

  • Read the root CLAUDE.md for slice boundaries and commit hygiene.
  • Changes to slices/write-queue/ or packages/queue/ need a concurrency test. No exceptions.
  • packages/* stays OSS-safe. No shared/*, no apps/*, no secrets.
  • Zod schemas are the source of truth. z.infer<> the types. Do not hand-write duplicates.
  • Run pnpm lint && pnpm typecheck before pushing.

Issues and discussions are open. I answer them.

Open an issue   Join discussions

License

MIT. Clone, fork, self-host, ship — no strings. The hosted SaaS runs on the same MIT core; the managed service is the product, the code stays free.


Built by @Devansh-365, on purpose.

If this saved you a bug, star it.


[ sheetforge ]