UNPKG

longcelot-sheet-db

Version:

Google Sheets-backed staging database adapter for Node.js with schema-first design

959 lines (715 loc) โ€ข 35.8 kB
# ๐Ÿ“ฆ longcelot-sheet-db [![CI](https://github.com/vannseavlong/longcelot-sheet-staging/actions/workflows/ci.yml/badge.svg)](https://github.com/vannseavlong/longcelot-sheet-staging/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/longcelot-sheet-db)](https://www.npmjs.com/package/longcelot-sheet-db) [![npm downloads](https://img.shields.io/npm/dt/longcelot-sheet-db)](https://www.npmjs.com/package/longcelot-sheet-db) [![license](https://img.shields.io/npm/l/longcelot-sheet-db)](LICENSE) **Google Sheets-backed Staging Database for Node.js** A schema-first, actor-aware database adapter that uses Google Sheets as the storage engine. Perfect for MVPs, prototypes, staging environments, and internal tools where cost and simplicity matter. ## ๐ŸŽฏ Purpose Instead of running MySQL, PostgreSQL, or MongoDB for staging: - Each user stores their data in **their own Google Sheet** - Admin maintains a **single centralized registry sheet** - Authentication powered by **Google OAuth** + optional password - Developers define schemas that are **automatically converted into sheet tables** ## โœจ Features - ๐Ÿ“ **Schema-First Design**: Define tables using a TypeScript DSL - ๐Ÿ” **Actor-Based Isolation**: Each user role owns their own sheet - ๐Ÿ”„ **Auto CRUD**: `create`, `createMany`, `findMany`, `findOne`, `count`, `update`, `upsert`, `delete` - ๐ŸŽญ **Role-Based Permissions**: Built-in security boundaries + cross-actor access matrix - ๐Ÿ”‘ **Authentication**: `createAuthRouter` wires Google Sign-In + JWT in one call; role-based registration policy - ๐Ÿ› ๏ธ **CLI Tools**: Initialize, generate, sync, validate, seed, migrate, drop/rename schema elements, mock-users - ๐Ÿ“Š **Type-Safe**: Full TypeScript support - ๐Ÿ’ฐ **Cost-Free**: No infrastructure costs for staging - ๐Ÿ”’ **Schema Integrity**: Hash-based version tracking detects stale user sheets at runtime - โ™ป๏ธ **Safe Migrations**: `sync --all-users` pushes schema changes to every user sheet with rate-limit backoff - ๐Ÿš€ **CI-Friendly**: `sync --token-file` skips interactive OAuth prompt in CI/CD pipelines ## ๐Ÿš€ Quick Start ### Installation ```bash # npm npm install longcelot-sheet-db # pnpm pnpm add longcelot-sheet-db # yarn yarn add longcelot-sheet-db # bun bun add longcelot-sheet-db ``` ### Initialize Project ```bash # npm npx lsdb init # pnpm pnpm dlx lsdb init # yarn yarn dlx lsdb init # bun bunx lsdb init ``` This creates: - `lsdb.config.ts` - Project configuration - `.env` - Environment variables - `schemas/` - Schema directory ### Set Up Google OAuth This package **requires Google OAuth2** to function โ€” there is no way to skip it. OAuth is used for the backend to communicate with Google Sheets API. 1. Go to [Google Cloud Console](https://console.cloud.google.com/) 2. Create a project and enable Google Sheets API and Google Drive API 3. Create OAuth 2.0 credentials (Client ID and Client Secret) 4. Set redirect URI (e.g., `http://localhost:3000/auth/callback`) 5. Add your credentials to `.env`: ```env GOOGLE_CLIENT_ID=your_client_id GOOGLE_CLIENT_SECRET=your_client_secret GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback ADMIN_SHEET_ID=your_admin_sheet_id ``` **What if you have your own authentication?** - OAuth is strictly for **backend-to-Google-Sheets** communication - Your app's existing authentication (JWT, sessions, etc.) remains untouched - You map your user identity to lsdb context (see "Integrating into an Existing Project" below) ### Define a Schema ```typescript import { defineTable, string, number, date } from 'longcelot-sheet-db'; export default defineTable({ name: 'bookings', actor: 'user', timestamps: true, columns: { booking_id: string().required().unique(), service: string().required(), date: date().required(), status: string().enum(['pending', 'confirmed', 'cancelled']).default('pending'), price: number().min(0), }, }); ``` ### Use in Your Application ```typescript import { createSheetAdapter } from 'longcelot-sheet-db'; import bookingsSchema from './schemas/user/bookings'; const adapter = createSheetAdapter({ adminSheetId: process.env.ADMIN_SHEET_ID, credentials: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, redirectUri: process.env.GOOGLE_REDIRECT_URI, }, tokens: userOAuthTokens, }); adapter.registerSchema(bookingsSchema); const userContext = adapter.withContext({ userId: 'user_123', actor: 'user', actorSheetId: 'user-sheet-id', }); await userContext.table('bookings').create({ booking_id: 'bk_001', service: 'Consultation', date: new Date().toISOString(), price: 100, }); const bookings = await userContext.table('bookings').findMany({ where: { status: 'pending' }, limit: 10, }); ``` ## ๐Ÿ“š Core Concepts ### Actors Actors are **data domains** โ€” they determine *where* data is stored (which Google Sheet and which table schemas apply). Each actor maps to a sheet ID via an environment variable: ```typescript // lsdb.config.ts actors: [ { name: "admin", sheetIdEnv: "ADMIN_SHEET_ID" }, { name: "user", sheetIdEnv: "DEV_USER_SHEET_ID" }, { name: "seller", sheetIdEnv: "DEV_SELLER_SHEET_ID" }, ] ``` > `ActorConfig.role` is accepted for backward compatibility but deprecated in favour of `name` โ€” `role` reads as an RBAC role at the exact spot autocomplete shows it, which is the confusion this field exists to prevent. See [Actors vs Application Roles](#actors-vs-application-roles) and [FAQ #2](./FAQ.md#2-actors-vs-rbac-roles). ```env # .env ADMIN_SHEET_ID=1ABCyourAdminSheetId DEV_USER_SHEET_ID=1DEFyourDevUserSheetId # optional for local dev DEV_SELLER_SHEET_ID=1GHIyourDevSellerSheetId # optional for local dev ``` - **admin**: Data stored in central admin sheet (always required) - **user / seller**: Each actor gets a personal sheet at runtime; `DEV_*_SHEET_ID` values let you sync schemas during development without registering real users `lsdb init` scaffolds all env vars automatically based on the actors you define. ### Actors vs Application Roles These two concepts are distinct โ€” confusing them leads to wrong architecture decisions: | Concept | What it controls | Dynamic? | Where defined | |---------|-----------------|----------|---------------| | **Actor** | *Where* data is stored (which Google Sheet, which schemas) | No โ€” fixed in `lsdb.config.ts` | Config file | | **App RBAC role** | *What* a user can do (read orders, edit products, etc.) | Yes โ€” rows in `roles` / `role_permissions` tables | Your app's DB layer | The `actor` field in `withContext()` is the lsdb actor concept, not an RBAC role. If you need fine-grained permissions (e.g. "manager can approve but not delete"), build a `roles` + `role_permissions` table in the admin sheet and enforce it in your application layer โ€” lsdb intentionally does not provide RBAC. **Field names follow the same rule everywhere actor identity appears** โ€” each was renamed away from `role` because the bare word `role` reads as an RBAC role at the point of writing the code, regardless of what the docs say: | Location | Preferred field | Deprecated alias (still works, warns) | |---|---|---| | `lsdb.config.ts` actor entries | `name` | `role` | | `withContext()` | `actor` | `role` | | `withContext()` cross-actor target | `targetActor` | `targetRole` | Modeling RBAC sub-roles (e.g. `operation`, `finance`, `marketing`) as separate actors is the most common version of this mistake โ€” it usually means you need rows in a `roles` / `role_permissions` table inside one actor, not one actor config entry per sub-role. ### Dev vs Production data model In development, each actor type shares **one** sheet (`DEV_SELLER_SHEET_ID` for all sellers). In production, `createUserSheet()` creates **one sheet per registered user**. This means: - Some bugs that only appear with per-user data isolation are invisible in dev. - Use `lsdb mock-users` to create separate actor sheets that mirror the production topology for more realistic local testing. > **Tip**: Add a "Dev vs Production" section to your own `README.md` noting which tests cover per-user-sheet scenarios. This same per-user isolation generalizes directly to the SQL adapters ([Migration Path](#-migration-path)): each non-admin table gets an injected `tenant_id` column, and `context.actorSheetId` โ€” a real spreadsheet ID on Sheets โ€” becomes the opaque `tenant_id` value on Postgres/MySQL/Prisma. Admin tables have no `tenant_id` column at all, matching Sheets' single global admin sheet. See [FAQ.md ยง13](./FAQ.md#13-sql-backend-portability--tenancy) for the full design rationale. ### Schema DSL Define tables using a fluent builder API: ```typescript { email: string().required().unique(), age: number().min(18).max(100), status: string().enum(['active', 'inactive']).default('active'), verified: boolean().default(false), metadata: json(), } ``` #### Column Modifiers - `required()` - Cannot be null - `unique()` - Enforced uniqueness - `default(value)` - Default value, applied on `create()` only (accepts arrays/objects for `json()` columns) - `min(n)` / `max(n)` - Validation constraints - `enum([...])` - Allowed values - `pattern(regex)` - Regex validation - `primary()` - Primary key - `readonly()` - Cannot be updated - `ref(table.column)` - Foreign key reference - `index()` - Create lookup index ### CRUD Operations ```typescript const table = ctx.table('bookings'); // Create await table.create({ service: 'Consultation', price: 100 }); // Bulk create โ€” single API call await table.createMany([ { service: 'Consultation', price: 100 }, { service: 'Follow-up', price: 50 }, ]); // Read await table.findMany({ where: { status: 'pending' }, orderBy: 'date', order: 'desc', limit: 10, offset: 0, }); await table.findOne({ where: { booking_id: 'bk_001' } }); // Count (without loading all rows) const total = await table.count({ where: { status: 'pending' } }); // Update await table.update({ where: { booking_id: 'bk_001' }, data: { status: 'confirmed' }, }); // Upsert (insert if not found, update if exists) await table.upsert({ where: { email: 'admin@example.com' }, data: { role: 'admin', status: 'active' }, }); // Delete await table.delete({ where: { booking_id: 'bk_001' } }); ``` ### Context & Permissions Every operation requires context: ```typescript const context = adapter.withContext({ userId: 'user_123', actor: 'user', // preferred โ€” maps to the actor data domain actorSheetId: 'sheet-id', }); // Note: role: is accepted for backward compatibility but deprecated in favour of actor: ``` Permissions are enforced automatically: - Users can only access their own sheets - Admin can access admin tables - Cross-actor access is blocked ### Schema Version Tracking `longcelot-sheet-db` computes a **SHA-256 hash** of every table schema and compares it against the hash stored in the built-in `schema_versions` admin table. When `withContext()` is called for a non-admin user, the check runs in the background โ€” every subsequent CRUD call awaits the result before proceeding. Configure the behaviour in `lsdb.config.ts`: ```typescript export default { // ... onSchemaMismatch: 'warn', // log warning and continue (default) // onSchemaMismatch: 'error', // throw SchemaMismatchError // onSchemaMismatch: 'auto-sync', // sync the actor sheet automatically }; ``` And pass it through to the adapter: ```typescript import { createSheetAdapter } from 'longcelot-sheet-db'; const adapter = createSheetAdapter({ adminSheetId: process.env.ADMIN_SHEET_ID, credentials: { clientId, clientSecret, redirectUri }, tokens: oauthTokens, onSchemaMismatch: 'warn', // or 'error' or 'auto-sync' }); ``` | Mode | Behaviour | |------|-----------| | `'warn'` | Log a warning to stderr and continue โ€” safe for production rollouts | | `'error'` | Throw `SchemaMismatchError` โ€” useful in staging to hard-fail stale clients | | `'auto-sync'` | Silently sync the actor sheet and update the version record before proceeding | When you push a schema change, run `lsdb sync --all-users` to propagate it to every registered user sheet and update the version records in one go: ```bash # Push schema changes to all user sheets npx lsdb sync --all-users # Preview what would change without applying npx lsdb sync --all-users --dry-run ``` ### Integrating into an Existing Project If you already have a working backend (e.g., Express, NestJS), you can safely inject `longcelot-sheet-db` without ripping out your framework: ```bash # 1. Add the package pnpm add longcelot-sheet-db # 2. Initialize project (creates config and schemas directory) npx lsdb init # 3. Update your .env with Google OAuth credentials # 4. Define your schemas in schemas/ directory # 5. Sync schemas to Google Sheets npx lsdb sync # 6. Use in your backend code ``` **How it works with your existing auth**: - Your app continues to use your existing authentication (JWT, sessions, cookies) - When you need to access data, map your authenticated user to lsdb context: ```typescript // Your Express/NestJS route handler app.get('/bookings', async (req, res) => { // Your existing auth provides user info const developerUser = req.user; // From your JWT/session // Map to lsdb context const userContext = adapter.withContext({ userId: developerUser.id, // Your app's user ID role: developerUser.role, // 'student', 'teacher', etc. actorSheetId: developerUser.sheetId, // From lsdb user registry }); const bookings = await userContext.table('bookings').findMany(); res.json(bookings); }); ``` ### Google Sign-In & Auth Routes For user-facing Google Sign-In, use `createLoginOAuthManager` (pre-configured with `openid email profile` scopes) and `createAuthRouter` to wire up the two required Express routes automatically. #### Login-only roles (admin / manager) ```typescript import express from 'express'; import { createSheetAdapter, createAuthRouter } from 'longcelot-sheet-db'; const adapter = createSheetAdapter({ ... }); const adminAuth = createAuthRouter({ adapter, jwtSecret: process.env.JWT_SECRET!, frontendUrl: process.env.FRONTEND_URL!, registrationPolicy: 'login-only', // user must already exist โ€” no self-signup async onUser(profile, adapter) { const ctx = adapter.withContext({ userId: 'auth', role: 'admin', actorSheetId: process.env.ADMIN_SHEET_ID!, }); return await ctx.table('users').findOne({ where: { email: profile.email } }); }, }); app.use(adminAuth.handler); // Exposes: GET /auth/google and GET /auth/callback ``` #### Open registration (regular users) ```typescript const userAuth = createAuthRouter({ adapter, jwtSecret: process.env.JWT_SECRET!, frontendUrl: process.env.FRONTEND_URL!, registrationPolicy: 'open', // allows self-signup (default) basePath: '/user', // โ†’ /user/auth/google, /user/auth/callback async onUser(profile, adapter) { const ctx = adapter.withContext({ userId: 'auth', role: 'admin', actorSheetId: process.env.ADMIN_SHEET_ID!, }); let user = await ctx.table('users').findOne({ where: { email: profile.email } }); if (!user) { // Auto-create the user and their sheet on first login const sheetId = await adapter.createUserSheet(profile.sub, 'user', profile.email); user = await ctx.table('users').findOne({ where: { email: profile.email } }); } return user; }, }); app.use(userAuth.handler); ``` **Registration policy summary:** | Policy | Behaviour | |--------|-----------| | `'open'` | Any Google-authenticated user can access; `onUser` returning `null` lets them in with bare profile | | `'login-only'` | `onUser` must return a non-null user; returns `401` if user is not found | #### Using `createLoginOAuthManager` directly If you prefer to wire up routes manually: ```typescript import { createLoginOAuthManager } from 'longcelot-sheet-db'; const oauth = createLoginOAuthManager({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, redirectUri: process.env.GOOGLE_REDIRECT_URI!, }); // Step 1: redirect to Google app.get('/auth/google', (req, res) => res.redirect(oauth.getAuthUrl())); // Step 2: handle callback app.get('/auth/callback', async (req, res) => { const tokens = await oauth.getTokens(req.query.code as string); const profile = await oauth.verifyToken((tokens as Record<string, string>).id_token); // ... lookup user, issue JWT }); ``` ### Why do we need `user_id` if we have `sheet_id`? The `sheet_id` dictates the **physical storage location** on Google Drive โ€” it exists only in the lsdb world. When you eventually graduate from Google Sheets to a production SQL database (MySQL, PostgreSQL), the `sheet_id` goes away entirely. The `user_id` dictates the **logical domain identity** โ€” it persists across all databases. This is your app's true primary key that ties your entire system together. | Field | Purpose | Persists after migration | |-------|---------|--------------------------| | `sheet_id` | Physical location in Google Drive | No โ€” Google Sheets only | | `user_id` | Logical user identity | Yes โ€” becomes PK in SQL | **Migration example**: When you export to Prisma, `user_id` becomes your primary key, while `sheet_id` is simply not included in the export. ## ๐Ÿ› ๏ธ CLI Commands > All commands can be run with `npx`, `pnpm dlx`, `yarn dlx`, or `bunx` โ€” or directly as `lsdb <command>` if installed globally. ### Initialize Project ```bash npx lsdb init # pnpm dlx lsdb init # yarn dlx lsdb init # bunx lsdb init ``` Creates project structure and configuration files. ### Generate Schema ```bash npx lsdb generate bookings # pnpm dlx lsdb generate bookings # yarn dlx lsdb generate bookings # bunx lsdb generate bookings ``` Interactive schema generator with prompts for columns and types. ### Sync Schemas ```bash npx lsdb sync # pnpm dlx lsdb sync # yarn dlx lsdb sync # bunx lsdb sync ``` Creates missing sheets and adds missing columns (never deletes data). Iterates **all actors** defined in `lsdb.config.ts` and prints a per-actor status table: ``` Actor โ”‚ Sheet ID โ”‚ Tables โ”‚ Status โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ admin โ”‚ 1ABCyourAdminSheetId โ”‚ 3 โ”‚ โœ… synced student โ”‚ 1DEFyourStudentSheetId โ”‚ 5 โ”‚ โœ… synced teacher โ”‚ (not set) โ”‚ 4 โ”‚ โš  skipped ``` Actors whose sheet ID env var is not set are skipped with a warning (non-fatal). **`--all-users`** โ€” after syncing dev actor sheets, reads all rows from the admin `users` table and pushes any missing columns/tables to every registered user sheet. Updates the `schema_versions` record for each one. Uses exponential backoff (1s โ†’ 32s) to handle Google Sheets API rate limits. **`--dry-run`** โ€” combine with `--all-users` to preview which user sheets are outdated without writing any changes: ```bash npx lsdb sync --all-users # apply npx lsdb sync --all-users --dry-run # preview only ``` **`--token-file <path>`** โ€” CI/CD-friendly: load a pre-stored tokens JSON file instead of triggering the interactive browser OAuth prompt. Inject the file from a CI secret: ```bash # In GitHub Actions: echo "$LSDB_TOKENS" > /tmp/tokens.json npx lsdb sync --token-file /tmp/tokens.json ``` ### Validate Schemas ```bash npx lsdb validate # pnpm dlx lsdb validate # yarn dlx lsdb validate # bunx lsdb validate ``` Checks for: - Duplicate table names - Invalid modifiers - Unknown actors - Missing required fields ### Seed Data ```bash npx lsdb seed <seed-file> # pnpm dlx lsdb seed seeds/admin.ts ``` Load initial or test data into your sheets. **Seed file formats** โ€” both are supported: ```typescript // Static export (simple) export default { users: [ { email: 'admin@example.com', role: 'admin', status: 'active' }, ], } // Dynamic export (receives process.env โ€” great for CI or per-environment seeds) export default async function(env: NodeJS.ProcessEnv) { return { users: [ { email: env.SUPER_ADMIN_EMAIL, role: 'admin', status: 'active' }, ], } } ``` **Flags:** - `--skip-existing` โ€” skip rows where a unique column already matches (no error on re-seed) - `--upsert` โ€” update existing rows on unique conflict instead of throwing - `--all-actors` โ€” distribute seed data to all registered user sheets ```bash npx lsdb seed seeds/admin.ts --skip-existing # idempotent re-seed npx lsdb seed seeds/admin.ts --upsert # update on conflict ``` ### Doctor ```bash npx lsdb doctor # pnpm dlx lsdb doctor # yarn dlx lsdb doctor # bunx lsdb doctor ``` Runs environment and configuration health checks. ### Status ```bash npx lsdb status # pnpm dlx lsdb status # yarn dlx lsdb status # bunx lsdb status ``` Shows all registered tables, actors, and their sheet IDs. ### ER Diagram ```bash npx lsdb erdiagram # pnpm dlx lsdb erdiagram # yarn dlx lsdb erdiagram # bunx lsdb erdiagram ``` Generates `ER-DIAGRAM.md` (Mermaid `erDiagram`) mapping every registered table, its columns, and `ref()` relationships โ€” offline, no Google Sheets calls. If the file already exists, prompts to overwrite, save under a different name, or cancel; pass `--yes` to overwrite non-interactively, or `--output <file>` to target a different path. ```bash npx lsdb erdiagram --output docs/schema.md npx lsdb erdiagram --yes ``` ## ๐Ÿ” Authentication ### Google OAuth ```typescript import { createOAuthManager } from 'longcelot-sheet-db'; const oauth = createOAuthManager({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, redirectUri: process.env.GOOGLE_REDIRECT_URI, }); const authUrl = oauth.getAuthUrl(); const tokens = await oauth.getTokens(code); const payload = await oauth.verifyToken(idToken); ``` ### Password Hashing ```typescript import { hashPassword, comparePassword, validatePasswordStrength } from 'longcelot-sheet-db'; const hash = await hashPassword('SecurePass123!'); const isValid = await comparePassword('SecurePass123!', hash); const { valid, errors } = validatePasswordStrength('password'); ``` ## ๐Ÿ“‹ Sheet Structure ### Central Admin Sheet - `users` - User registry with `actor_sheet_id` per user - `credentials` - Authentication data - `schema_versions` - Schema hash per `(actor_sheet_id, table_name)` โ€” used for mismatch detection ### User-Owned Sheets Each user gets their own sheet with tables based on their role: ``` user-sheet-123 โ”œโ”€โ”€ profile โ”œโ”€โ”€ bookings โ”œโ”€โ”€ payments โ””โ”€โ”€ settings ``` ### Sheet Formatting Every tab created or extended by `sync` / `syncSchema()` / `createUserSheet()` is formatted automatically โ€” no config needed: - **Auto-fit columns** โ€” header and data columns are resized to fit their content. - **Header row styling** โ€” a light fill color, frozen by default so it stays visible while scrolling. - **Data validation dropdowns** โ€” `boolean()` columns get a dropdown restricted to `TRUE`/`FALSE` (or `1`/`0`, see below); `string().enum([...])` columns get a dropdown of the allowed values. Both guard against invalid manual edits directly in the sheet. Override the defaults via `sheetStyle` on `createSheetAdapter()`: ```typescript const adapter = createSheetAdapter({ // ... sheetStyle: { headerColor: '#E8F0FE', // optional, falls back to this built-in default freezeHeader: true, // default: true freezeFirstColumn: false, // default: false booleanFormat: 'TRUE_FALSE', // default: 'TRUE_FALSE', or '1_0' }, }); ``` Override per column instead of project-wide with `boolean({ format: '1_0' })` โ€” useful when one table needs to match an external system's convention without changing every other table. ### Read Caching & Rate Limits `findMany()`, `findOne()`, `count()`, `update()`, and `delete()` all read through an in-memory cache in `SheetClient`, enabled by default with a 2-second TTL โ€” repeated or concurrent reads of the same tab within that window are served from cache or de-duplicated into a single Sheets API call instead of one call each. This exists because Google's default quota (60 read requests/min/user) is easy to exhaust once more than a couple of concurrent users are hitting the same tables โ€” see FAQ.md #11 for the incident that motivated it. Every write invalidates the cache for the tab it touched, so a read right after a write always sees fresh data. Tune or disable it via `cache` on `createSheetAdapter()`: ```typescript const adapter = createSheetAdapter({ // ... cache: { ttlMs: 5000 }, // default: 2000ms; set enabled: false to disable }); ``` ## ๐ŸŽ“ Complete Example Coming Soon! ## ๐Ÿ”„ Migration Path When you're ready for production, swapping `createSheetAdapter` for a real database is a **config/factory change, not a CRUD-call rewrite** โ€” `SheetAdapter` and the SQL adapters below all implement the same `DatabaseAdapter`/`TableOperations` contract, so `adapter.withContext({...}).table('products').create({...})` reads identically either way. See [FAQ.md ยง13](./FAQ.md#13-sql-backend-portability--tenancy) for the tenancy design behind this. ```typescript import { createDatabaseAdapter } from 'longcelot-sheet-db'; // Picks the engine from $DB_DRIVER ('sheets' | 'postgres' | 'mysql'), or pass driver explicitly. // A dev's .env points at sheets; CI/production's env points at postgres/mysql โ€” zero code branching. const adapter = createDatabaseAdapter(); adapter.registerSchemas(schemas); ``` ```typescript // Or construct a specific engine directly: import { createSheetAdapter, createPostgresAdapter, createMySQLAdapter, createPrismaAdapter } from 'longcelot-sheet-db'; const dev = createSheetAdapter({ adminSheetId, credentials, tokens }); const prod = createPostgresAdapter({ connectionString: process.env.DATABASE_URL }); const prodMysql = createMySQLAdapter({ connectionString: process.env.DATABASE_URL }); // Prisma: pass an already-`prisma generate`'d client โ€” this package never runs Prisma codegen // itself (see FAQ.md ยง13). Run `lsdb migrate --prisma` + `prisma generate` first. import { PrismaClient } from '@prisma/client'; const prodPrisma = createPrismaAdapter({ client: new PrismaClient() }); ``` Install only the driver you target โ€” `pg`/`mysql2` are optional peer dependencies, lazily required only inside `createPostgresAdapter()`/`createMySQLAdapter()`, so `npm install longcelot-sheet-db` alone never pulls either in: ```bash npm install longcelot-sheet-db npm install pg # only if targeting Postgres npm install mysql2 # only if targeting MySQL npm install prisma @prisma/client # only if targeting Prisma ``` ### Which migrate command do I need? | Goal | Command | |------|---------| | Copy table structure only (schema / DDL) | `lsdb migrate --prisma` or `--sql` | | Apply that DDL to a live Postgres/MySQL database | `lsdb migrate --sql --apply --connection-string $DATABASE_URL` | | Copy structure + admin sheet row data | `lsdb migrate-data` | | Copy structure + all user-sheet row data | `lsdb migrate-data --all-users` | | Run the data cutover now, no generated script | `lsdb migrate-data --run --connection-string $DATABASE_URL --driver postgres` | | Preview any of the above without writing/executing | add `--dry-run` | ### Schema export (structure only) ```bash # Export to Prisma schema npx lsdb migrate --prisma --output ./prisma # Export to SQL DDL (CREATE TABLE statements) npx lsdb migrate --sql --output ./migrations # Export AND apply directly to a live database โ€” idempotent, safe to rerun npx lsdb migrate --sql --apply --connection-string postgres://user:pass@host/db npx lsdb migrate --sql --apply --driver mysql --connection-string $DATABASE_URL # Prisma: shells out to `prisma migrate deploy` (needs an existing migrations/ folder โ€” # run `prisma migrate dev` once locally first) npx lsdb migrate --prisma --apply ``` `--driver` is inferred from the connection string's scheme (`postgres://`/`postgresql://` โ†’ postgres, `mysql://` โ†’ mysql) when omitted. `--dry-run` prints the statements that would run without executing them. ### Data export (row data โ†’ production DB) ```bash # Generate a migrate-data.js script (admin sheet only) npx lsdb migrate-data # Admin sheet + all registered user sheets npx lsdb migrate-data --all-users # Preview without writing npx lsdb migrate-data --all-users --dry-run # Run the cutover now, in-process โ€” no generated script, no insertRow() stub to fill in npx lsdb migrate-data --run --all-users --connection-string $DATABASE_URL --driver postgres ``` Without `--run`, `migrate-data` generates a `migrate-data.js` script โ€” replace the `insertRow()` stub with your real DB client and run it once. With `--run`, the cutover happens immediately against `createPostgresAdapter`/`createMySQLAdapter` (not Prisma โ€” see [FAQ.md ยง13](./FAQ.md#13-sql-backend-portability--tenancy) for why), upserting every row by `_id` so reruns are safe by default โ€” no separate `--upsert` flag needed. `--token-file` works the same as it does for `sync --token-file`, for CI/CD. > **Note**: `lsdb export` and `lsdb export-data` are deprecated โ€” use `lsdb migrate` and `lsdb migrate-data` instead. In standard tooling (Prisma Migrate, Rails, Flyway), "migrate" means schema-only DDL changes, so the schema/DDL export command is now the one named `migrate`; the row-data export command is `migrate-data`. ### Reference CI/CD pipeline `--apply`/`--run` are built to be invoked unattended from a deploy pipeline โ€” same secrets convention as `sync --token-file` (`$DATABASE_URL` / a secret manager entry for the connection string, a stored tokens file for the Sheets read side), idempotent by default, `--dry-run` on both for a gated diff-review step. A typical GitHub Actions job: ```yaml # In GitHub Actions: jobs: deploy: steps: - uses: actions/checkout@v4 - run: pnpm install --frozen-lockfile && pnpm build # Schema: apply to staging, smoke test, then production only on a version tag โ€” # mirrors this package's own `publish` job's `if: startsWith(github.ref, 'refs/tags/v')` gate. - run: npx lsdb migrate --sql --apply --connection-string ${{ secrets.STAGING_DATABASE_URL }} - run: <your smoke test against staging> - if: startsWith(github.ref, 'refs/tags/v') run: npx lsdb migrate --sql --apply --connection-string ${{ secrets.PRODUCTION_DATABASE_URL }} # Data cutover โ€” run once (or repeatedly during a dual-write transition window, see below), # not on every deploy. - if: startsWith(github.ref, 'refs/tags/v') run: | npx lsdb migrate-data --run --all-users \ --connection-string ${{ secrets.PRODUCTION_DATABASE_URL }} \ --driver postgres \ --token-file token.json ``` **When does "ship to production" actually happen?** Two options: - **Dual-write-then-flip (recommended default)** โ€” both stores stay populated during a transition window: keep writing to Sheets as usual, rerun `migrate-data --run` periodically to keep the SQL side current (safe โ€” always idempotent), then flip `DB_DRIVER=postgres` (via `createDatabaseAdapter()`) at deploy time once you're confident. Rollback is just flipping the env var back, since Sheets was never stopped. - **One-shot cutover** โ€” run `migrate-data --run` once, flip the driver, and stop writing to Sheets entirely. Simpler, but harder to roll back from: the SQL adapters never auto-create/alter schema at runtime (ยง16.2), so there's no automatic "flip back and resync" path once Sheets stops being the source of truth. See [FAQ.md ยง13](./FAQ.md#13-sql-backend-portability--tenancy) for the full tenancy/cutover design rationale. ## ๐Ÿ—‘๏ธ Dropping & Renaming Schema Elements `lsdb sync` is deliberately **additive-only** โ€” it creates tabs and appends missing columns, but never removes or renames anything, so it can never lose data on its own. Once a table or column is no longer needed (or needs a better name), use these commands to update the schema file *and* the live Google Sheet together: ```bash # Drop table(s) โ€” deletes the schema file and the Google Sheet tab npx lsdb drop-table bookings npx lsdb drop-table # interactive checkbox โ€” pick any number of tables npx lsdb drop-table --all-users # also drop from every registered user's personal sheet # Drop column(s) from a table npx lsdb drop-column bookings notes npx lsdb drop-column bookings # interactive checkbox over that table's columns npx lsdb drop-column # interactive: pick the table, then its columns # Rename a column โ€” updates the header cell in place, existing row data is preserved npx lsdb rename-column bookings notes remarks npx lsdb rename-column # interactive: pick table, pick column, type new name ``` All three: - Print a plan and ask for confirmation before touching anything (skip with `--yes`) - Support `--dry-run` to preview without making changes - Support `--all-users` to also apply the change to every registered user's personal sheet (reads `actor_sheet_id` from the admin `users` table, same as `sync --all-users`) - Support `--token-file <path>` for CI - Refuse to touch reserved auto-generated columns (`_id`, `_created_at`, `_updated_at`, `_deleted_at`); `drop-column` also refuses to drop a table's primary key column (drop the whole table instead) - Warn โ€” but don't block โ€” if another table's `ref()` points at the table/column being dropped or renamed, since `ref()` strings in *other* schema files aren't rewritten automatically `rename-column` is the safe way to fix a column name: it edits the Google Sheet header cell in place instead of dropping and re-adding the column, so every existing row keeps its data. This matters most for production, where a bad rename that instead did drop+re-add would silently wipe that column's data across every registered user's sheet the next time it synced. ## โšก Performance - Suitable for **hundreds to low thousands** of rows - Not suitable for millions of rows - Read operations: ~200-500ms - Write operations: ~300-700ms ## ๐Ÿ”’ Security - bcrypt password hashing (10 rounds) - OAuth tokens never stored in plain text - Sheets private by default - Role validation on every request - No SQL injection risk ## ๐Ÿ“ฆ Architecture ``` Developer Backend โ†“ longcelot-sheet-db SDK โ†“ Google OAuth2 โ†’ Google Sheets API โ†“ Central Admin Sheet โ†“ User-Owned Sheets ``` ## ๐ŸŽฏ Use Cases Perfect for: - โœ… MVPs and prototypes - โœ… Staging environments - โœ… Internal tools - โœ… School/small business apps - โœ… Proof of concepts Not suitable for: - โŒ Production at scale - โŒ High-performance applications - โŒ Real-time analytics - โŒ Millions of records ## ๐Ÿค Contributing Contributions welcome! This package is designed to be: - Simple over clever - Explicit over implicit - Safe over fast ## ๐Ÿ“„ License MIT ## ๐Ÿ™ Acknowledgments Built on: - [Google Sheets API](https://developers.google.com/sheets/api) - [googleapis](https://github.com/googleapis/google-api-nodejs-client) - [bcryptjs](https://github.com/dcodeIO/bcrypt.js) - [commander](https://github.com/tj/commander.js) - [inquirer](https://github.com/SBoudrias/Inquirer.js) --- **Note**: This is a staging database solution. For production workloads, migrate to MySQL, PostgreSQL, or MongoDB.