UNPKG

longcelot-sheet-db

Version:

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

1,270 lines (912 loc) 43.9 kB
# API Documentation ## Table of Contents - [Schema Definition](#schema-definition) - [Column Builders](#column-builders) - [Sheet Adapter](#sheet-adapter) - [SQL Adapters](#sql-adapters) - [CRUD Operations](#crud-operations) - [Authentication](#authentication) - [CLI Commands](#cli-commands) - [Cross-Actor Operations](#cross-actor-operations) - [Type Definitions](#type-definitions) ## Schema Definition ### `defineTable(config)` Defines a table schema. **Parameters:** ```typescript { name: string; // Table name actor: string; // Actor that owns this table timestamps?: boolean; // Add _created_at, _updated_at (default: false) softDelete?: boolean; // Add _deleted_at (default: false) columns: { [columnName: string]: ColumnBuilder | ColumnDefinition; }; } ``` **Returns:** `TableSchema` **Example:** ```typescript import { defineTable, string, number } from 'longcelot-sheet-db'; const bookingsSchema = defineTable({ name: 'bookings', actor: 'user', timestamps: true, columns: { booking_id: string().required().unique(), price: number().min(0), }, }); ``` ## Column Builders ### `string()` Creates a string column. **Modifiers:** - `.required()` - Cannot be null - `.unique()` - Must be unique - `.default(value)` - Default value - `.min(length)` - Minimum length - `.max(length)` - Maximum length - `.enum(values)` - Allowed values - `.pattern(regex)` - Regex validation - `.primary()` - Primary key - `.readonly()` - Cannot be updated - `.ref(table.column)` - Foreign key reference - `.index()` - Create index **Example:** ```typescript email: string().required().unique().min(5).max(100) status: string().enum(['active', 'inactive']).default('active') ``` ### `number()` Creates a number column. **Modifiers:** - `.required()` - `.unique()` - `.default(value)` - `.min(value)` - Minimum value - `.max(value)` - Maximum value **Example:** ```typescript age: number().min(0).max(120) price: number().min(0).required() ``` ### `boolean()` Creates a boolean column. Renders as a `ONE_OF_LIST` dropdown in the sheet (not a native checkbox — see [`SheetStyleConfig`](#sheetstyleconfig) and FAQ.md #10 for why) restricted to `TRUE`/`FALSE` by default, or `1`/`0` if configured. **Parameters:** - `options?: { format?: 'TRUE_FALSE' | '1_0' }` — overrides the project-wide `sheetStyle.booleanFormat` default for this column only. **Modifiers:** - `.required()` - `.default(value)` - true or false **Example:** ```typescript is_active: boolean().default(true) verified: boolean().required() legacy_flag: boolean({ format: '1_0' }) // overrides the project-wide default for this column ``` ### `date()` Creates a date column (stored as ISO string). Accepts either a `Date` instance or an ISO string on `create()`/`update()` — a `Date` is normalized to `.toISOString()` before being written, so both forms produce the same clean cell text. `findMany()`/`findOne()` always return a plain ISO string (never a `Date` instance), matching the auto-managed `_created_at`/`_updated_at` columns. **Modifiers:** - `.required()` - `.default(value)` **Example:** ```typescript birth_date: date().required() expires_at: date() // Both write the same clean ISO string — pass either directly, no manual .toISOString() needed: await table('events').create({ expires_at: new Date() }) await table('events').create({ expires_at: '2026-07-14T03:00:00.000Z' }) ``` ### `json()` Creates a JSON column (stored as JSON string). **Modifiers:** - `.required()` - `.default(value)` **Example:** ```typescript metadata: json() settings: json().default({}) ``` ## Sheet Adapter ### `createSheetAdapter(config)` Creates a new sheet adapter instance. **Parameters:** ```typescript { adminSheetId: string; credentials: { clientId: string; clientSecret: string; redirectUri: string; }; tokens: unknown; // admin OAuth tokens object onSchemaMismatch?: 'warn' | 'error' | 'auto-sync'; permissions?: Record<string, ActorPermission>; // cross-actor permission matrix driveFolder?: DriveFolderConfig; // folder organisation in Drive sharedDriveId?: string; // target a Google Workspace Shared Drive tokenStore?: TokenStore; // per-actor token persistence storage?: StorageAdapter; // file upload provider sheetStyle?: SheetStyleConfig; // header color, frozen rows/columns (see Type Definitions) cache?: SheetReadCacheConfig; // read cache tuning — see Type Definitions and FAQ.md #11 } ``` **Returns:** `SheetAdapter` **Example:** ```typescript 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: userTokens, }); ``` ### `adapter.registerSchema(schema)` Registers a single schema. **Parameters:** - `schema: TableSchema` **Example:** ```typescript adapter.registerSchema(bookingsSchema); ``` ### `adapter.registerSchemas(schemas)` Registers multiple schemas at once. **Parameters:** - `schemas: TableSchema[]` **Example:** ```typescript adapter.registerSchemas([usersSchema, bookingsSchema, paymentsSchema]); ``` ### `adapter.withContext(context)` Creates a new adapter instance with user context. Optionally starts an async schema version check when `onSchemaMismatch` is configured. **Parameters:** ```typescript { userId: string; actor: string; // preferred — the data domain (e.g. 'admin', 'seller', 'student') role?: string; // @deprecated — use actor instead (accepted for backward compat, emits console.warn) actorSheetId?: string; // Cross-actor fields (see Cross-Actor Operations below) targetActor?: string; // preferred — which actor's sheet to access targetRole?: string; // @deprecated — use targetActor instead (accepted for backward compat, emits console.warn) targetSheetId?: string; } ``` **Returns:** `SheetAdapter` with context **Example:** ```typescript const userContext = adapter.withContext({ userId: 'user_123', actor: 'student', // preferred actorSheetId: 'sheet-id-xyz', }); ``` ### `adapter.asActor(targetActor, targetSheetId)` Convenience methodclones the current context and sets cross-actor fields. Requires `withContext()` to have been called first. **Parameters:** - `targetActor: string` - The actor type to access - `targetSheetId: string` - The sheet ID of the target actor **Returns:** `SheetAdapter` pointing at the target actor's sheet **Example:** ```typescript // Teacher accessing a student's sheet const teacherCtx = adapter.withContext({ userId: 'teacher_001', actor: 'teacher', actorSheetId: 'teacher-sheet-id', }); const studentCtx = teacherCtx.asActor('student', 'student-sheet-id-123'); const scores = await studentCtx.table('scores').findMany({}); ``` ### `adapter.table(tableName)` Gets CRUD operations for a table. **Parameters:** - `tableName: string` **Returns:** `CRUDOperations` **Example:** ```typescript const bookings = adapter.table('bookings'); ``` ### `adapter.createUserSheet(userId, role, email, options?)` Creates a new sheet for a user and registers them in the admin `users` table. When `actorTokens` are provided (or resolved via `tokenStore`), the sheet is created in **the actor's own Google Drive**. Otherwise the admin's Drive is used (previous behaviour). **Parameters:** - `userId: string` - `role: string` - `email: string` - `options?: CreateUserSheetOptions` - `actorTokens?: OAuthTokens` — actor's own Google OAuth tokens; sheet is created in their Drive - `extraFields?: Record<string, unknown>` — extra columns spread into the `users` table `create()` call **Returns:** `Promise<string>` - Sheet ID **Example:** ```typescript // Basic (sheet in admin's Drive) const sheetId = await adapter.createUserSheet('user_123', 'student', 'student@school.com'); // Actor-owned (sheet in student's Drive) const sheetId = await adapter.createUserSheet('user_123', 'student', 'student@school.com', { actorTokens: { access_token: '...', refresh_token: '...' }, extraFields: { display_name: 'Alice' }, }); ``` ### `adapter.upload(file, options)` Uploads a file using the configured `StorageAdapter`. Throws `SchemaError` if no storage adapter is configured. **Parameters:** - `file: Buffer` - `options: UploadOptions` - `filename: string` - `mimeType: string` - `folder?: string` — subfolder path; created if missing (e.g. `'uploads/products'`) - `public?: boolean` — make the file publicly readable (Drive: sets `anyone / reader` permission) **Returns:** `Promise<string>` - Public URL **Example:** ```typescript import { DriveStorageAdapter } from 'longcelot-sheet-db'; const adapter = createSheetAdapter({ ..., storage: new DriveStorageAdapter({ folder: 'uploads' }), }); const url = await adapter.upload(imageBuffer, { filename: 'product.jpg', mimeType: 'image/jpeg', public: true, }); // 'https://drive.google.com/uc?id=FILE_ID' ``` ### `adapter.deleteFile(url)` Deletes a file via the configured `StorageAdapter`. Throws `SchemaError` if no storage adapter is configured. **Parameters:** - `url: string` - URL previously returned by `adapter.upload()` **Returns:** `Promise<void>` ### `adapter.syncSchema(schema)` Syncs a schema to Google Sheets. Creates the tab if missing, appends any new columns, and — whenever headers are written (new tab or new columns) — applies sheet formatting: auto-fit column widths, header fill color, frozen header row (and first column if configured), and `BOOLEAN`/`ONE_OF_LIST` data validation dropdowns for `boolean()`/`enum()` columns. No formatting calls are made when nothing changed. Configure via `sheetStyle` on `createSheetAdapter()` (see [`SheetStyleConfig`](#sheetstyleconfig)). **Parameters:** - `schema: TableSchema` **Returns:** `Promise<void>` **Example:** ```typescript await adapter.syncSchema(bookingsSchema); ``` ## SQL Adapters Postgres, MySQL, and Prisma-backed alternatives to `createSheetAdapter` — all implement the same `DatabaseAdapter`/`TableOperations` contract (see [Type Definitions](#databaseadapter)), so `adapter.withContext({...}).table(name).create({...})` calls are identical regardless of engine. See [FAQ.md §13](./FAQ.md#13-sql-backend-portability--tenancy) for the tenancy design (`tenant_id` column injection) and the cross-engine bugs found while building these. ### createPostgresAdapter(config) ```typescript function createPostgresAdapter(config: PostgresAdapterConfig): SQLAdapterBase; // implements DatabaseAdapter ``` `pg` is an optional peerDependency, lazily `require()`'d only inside this factory — throws `SchemaError` with an install hint if missing and no `pool` was passed. See [`PostgresAdapterConfig`](#postgresadapterconfig). ```typescript const adapter = createPostgresAdapter({ connectionString: process.env.DATABASE_URL }); adapter.registerSchemas([productsSchema, ordersSchema]); ``` ### createMySQLAdapter(config) ```typescript function createMySQLAdapter(config: MySQLAdapterConfig): SQLAdapterBase; // implements DatabaseAdapter ``` Same shape as `createPostgresAdapter`, backed by `mysql2/promise` (also an optional peerDependency, lazily required). See [`MySQLAdapterConfig`](#mysqladapterconfig). ### createPrismaAdapter(config) ```typescript function createPrismaAdapter(config: PrismaAdapterConfig): PrismaAdapterBase; // implements DatabaseAdapter ``` Takes an **already-constructed, already-`prisma generate`'d `PrismaClient` instance** — this package never performs Prisma codegen or migrations itself (see FAQ.md §13 for why). Run `lsdb migrate --prisma` to produce `schema.prisma`, then `prisma generate` as a normal build step. ```typescript import { PrismaClient } from '@prisma/client'; const adapter = createPrismaAdapter({ client: new PrismaClient() }); adapter.registerSchemas([productsSchema, ordersSchema]); ``` ### createDatabaseAdapter(config?) ```typescript function createDatabaseAdapter(config?: DatabaseAdapterFactoryConfig): DatabaseAdapter; ``` Single top-level factory — picks the engine from `config.driver`, falling back to `$DB_DRIVER`, falling back to `'sheets'`. Simultaneously the "one config value picks the engine" story and the env-driven CI/CD story (a dev's `.env` sets `DB_DRIVER=sheets`; a pipeline's env sets `DB_DRIVER=postgres` + `$DATABASE_URL`), with zero application code branching. **Does not support `driver: 'prisma'`** — there's no env var that can hold a live `PrismaClient` object; Prisma-track consumers keep one line of branching in their own app instead (see FAQ.md §13). ```typescript const adapter = createDatabaseAdapter(); // reads $DB_DRIVER, or defaults to 'sheets' adapter.registerSchemas(schemas); ``` **Config shapes:** ```typescript interface PostgresAdapterConfig { connectionString?: string; // falls back to $DATABASE_URL pool?: unknown; // pass a pre-built pg.Pool instead of letting the adapter construct one tenantColumn?: string; // default: 'tenant_id' permissions?: Record<string, ActorPermission>; } interface MySQLAdapterConfig { connectionString?: string; // falls back to $DATABASE_URL pool?: unknown; // pass a pre-built mysql2/promise pool tenantColumn?: string; // default: 'tenant_id' permissions?: Record<string, ActorPermission>; } interface PrismaAdapterConfig { client: unknown; // an already-constructed, already-generated PrismaClient tenantColumn?: string; // default: 'tenant_id' permissions?: Record<string, ActorPermission>; } interface DatabaseAdapterFactoryConfig { driver?: 'sheets' | 'postgres' | 'mysql'; // falls back to $DB_DRIVER, then 'sheets' sheets?: SheetAdapterConfig; postgres?: PostgresAdapterConfig; mysql?: MySQLAdapterConfig; } ``` > **Config that doesn't carry over**: `sheetStyle`, `cache`, `driveFolder`, `sharedDriveId`, and `onSchemaMismatch` are Sheets-specific and have no equivalent on the SQL adapters — they were never part of the shared `DatabaseAdapter` contract. The SQL adapters also never auto-create/alter tables at runtime (unlike `SheetAdapter.syncSchema()`); schema application is a deploy-time step — see [`lsdb migrate --apply`](#lsdb-migrate). ## CRUD Operations > **Read caching**: `findMany()`, `findOne()`, `count()`, `update()`, and `delete()` all read through `SheetClient.getAllRows()`, which caches each tab's rows in memory for a short TTL (default 2s, enabled by default) and de-duplicates concurrent reads for the same tab into a single Sheets API request. This exists to keep normal usage under Google's read-quota limits — see [`SheetReadCacheConfig`](#sheetreadcacheconfig) to tune it and FAQ.md #11 for the incident that motivated it. Every write (`create`, `update`, `delete`, `createMany`) invalidates the cache for the tab it touched, so a read immediately after a write through the same adapter instance always sees fresh data. ### `table.create(data, options?)` Creates a new row. **Parameters:** - `data: Record<string, unknown>` - Row data - `options?: { skipFKValidation?: boolean }` - Skip FK checks for bulk seeding **Returns:** `Promise<Record<string, unknown>>` - Created row with generated fields **Example:** ```typescript const booking = await table.create({ booking_id: 'bk_001', service: 'Consultation', date: new Date().toISOString(), price: 100, }); ``` ### `table.findMany(options)` Finds multiple rows. **Parameters:** ```typescript { where?: Record<string, unknown>; limit?: number; offset?: number; orderBy?: string; order?: 'asc' | 'desc'; includeDeleted?: boolean; // see softDelete below — default: false } ``` **Returns:** `Promise<Record<string, unknown>[]>` **Example:** ```typescript const bookings = await table.findMany({ where: { status: 'pending' }, orderBy: 'date', order: 'desc', limit: 10, }); ``` When the table's schema has `softDelete: true`, rows with a populated `_deleted_at` are excluded automatically. Pass `includeDeleted: true` to see them anyway. ### `table.findOne(options)` Finds a single row. Like `findMany()`, excludes soft-deleted rows unless `includeDeleted: true` is passed. **Parameters:** ```typescript { where?: Record<string, unknown>; includeDeleted?: boolean; } ``` **Returns:** `Promise<Record<string, unknown> | null>` **Example:** ```typescript const booking = await table.findOne({ where: { booking_id: 'bk_001' }, }); ``` ### `table.update(options)` Updates rows matching criteria. Column `default()` values are **never** applied here — a field omitted from `data` keeps its existing value rather than being reset to the schema default. Defaults only apply on `create()`. **Parameters:** ```typescript { where: Record<string, unknown>; data: Record<string, unknown>; skipFKValidation?: boolean; } ``` **Returns:** `Promise<number>` - Number of rows updated **Example:** ```typescript const updated = await table.update({ where: { booking_id: 'bk_001' }, data: { status: 'confirmed' }, }); ``` ### `table.delete(options)` Deletes rows matching criteria. If `softDelete: true` is set on the schema, sets `_deleted_at` instead of removing the row — the row is then excluded from `findMany()`/`findOne()`/`count()` by default (pass `includeDeleted: true` to see it). **Parameters:** ```typescript { where: Record<string, unknown>; } ``` **Returns:** `Promise<number>` - Number of rows deleted **Example:** ```typescript const deleted = await table.delete({ where: { booking_id: 'bk_001' }, }); ``` ## Authentication ### `createOAuthManager(config)` Creates an OAuth manager. **Parameters:** ```typescript { clientId: string; clientSecret: string; redirectUri: string; } ``` **Returns:** `OAuthManager` ### `oauth.getAuthUrl()` Gets the OAuth authorization URL. **Returns:** `string` **Example:** ```typescript const authUrl = oauth.getAuthUrl(); // Redirect user to authUrl ``` ### `oauth.getTokens(code)` Exchanges authorization code for tokens. **Parameters:** - `code: string` - Authorization code from OAuth callback **Returns:** `Promise<unknown>` - OAuth tokens (save to `.lsdb-tokens.json`) ### `oauth.refreshTokens(refreshToken)` Refreshes expired tokens. **Parameters:** - `refreshToken: string` **Returns:** `Promise<unknown>` - New tokens ### `oauth.verifyToken(idToken)` Verifies an ID token. **Parameters:** - `idToken: string` **Returns:** `Promise<unknown>` - Token payload ### `hashPassword(password)` Hashes a password using bcrypt. **Parameters:** - `password: string` **Returns:** `Promise<string>` - Hashed password **Example:** ```typescript const hash = await hashPassword('SecurePass123!'); ``` ### `comparePassword(password, hash)` Compares a password with a hash. **Parameters:** - `password: string` - Plain text password - `hash: string` - Hashed password **Returns:** `Promise<boolean>` **Example:** ```typescript const isValid = await comparePassword('SecurePass123!', hash); ``` ### `validatePasswordStrength(password)` Validates password strength. **Parameters:** - `password: string` **Returns:** ```typescript { valid: boolean; errors: string[]; } ``` **Example:** ```typescript const { valid, errors } = validatePasswordStrength('weak'); if (!valid) { console.log(errors); // ["Password must be at least 8 characters long", ...] } ``` ## CLI Commands ### `lsdb init [--integrate]` Initializes a new project. With `--integrate`, merges into an existing project without overwriting files. **Creates:** - `lsdb.config.ts` — actor config with per-actor `sheetIdEnv` mappings - `.env` — Google OAuth vars + one `DEV_<ROLE>_SHEET_ID` per non-admin actor - `schemas/admin/` — scaffolds `users`, `credentials`, `schema_versions` tables ```bash npx lsdb init npx lsdb init --integrate # safe merge into existing project ``` ### `lsdb generate <table-name>` Interactively generates a new table schema file. ```bash npx lsdb generate bookings ``` ### `lsdb sync [--all-users] [--dry-run]` Syncs all schemas to Google Sheets. Iterates every configured actor and prints a per-actor status table: Actor | Sheet ID | Tables | Status. - `--all-users` — also pushes schema changes to every registered user sheet (reads `actor_sheet_id` values from admin `users` table, skips sheets that are already up-to-date via schema hash comparison) - `--dry-run` — preview `--all-users` changes without applying them (requires `--all-users`) ```bash npx lsdb sync npx lsdb sync --all-users npx lsdb sync --all-users --dry-run ``` ### `lsdb validate` Validates all schema files. **Checks:** - Duplicate table names - Unknown actors - Missing required fields - Invalid enum / min > max ### `lsdb seed <seed-file> [--all-actors]` Seeds data from a JS/TS file (exporting `Record<string, unknown[]>`). - `--all-actors` — distributes seed records to every user's actor sheet (reads from admin `users` table) ```bash npx lsdb seed ./seeds/initial.js npx lsdb seed ./seeds/initial.js --all-actors ``` ### `lsdb mock-users [count]` Creates `count` mock Google Sheets for development (default: 3). Rotates through configured non-admin actor roles. ```bash npx lsdb mock-users npx lsdb mock-users 5 ``` ### `lsdb migrate [--prisma] [--sql] [--output <dir>] [--apply] [--connection-string <url>] [--driver <driver>] [--dry-run]` Exports registered schemas to production DB formats, and optionally applies them to a live database. - `--prisma` — writes `schema.prisma` (Prisma DSL) - `--sql` — writes `schema.sql` (SQL DDL `CREATE TABLE` statements) - `--output <dir>` — output directory (default: current directory) - `--apply` — apply the generated DDL to a live database. With `--sql`, executes each statement (lazy-requires `pg`/`mysql2`, same optional-peerDependency pattern as `createPostgresAdapter`/`createMySQLAdapter`) and treats a native "already exists" error as success, so reruns are idempotent even though plain `CREATE INDEX` has no `IF NOT EXISTS` clause on MySQL. With `--prisma`, shells out to `npx prisma migrate deploy` (needs an existing `migrations/` folder — run `prisma migrate dev` once locally first). - `--connection-string <url>` — target DB for `--apply` (falls back to `$DATABASE_URL`) - `--driver <postgres|mysql>` — inferred from the connection string's scheme when omitted (`postgres://`/`postgresql://` → postgres, `mysql://` → mysql) - `--dry-run` — with `--apply`, print the statements/command that would run without executing them ```bash npx lsdb migrate --prisma --output ./prisma npx lsdb migrate --sql npx lsdb migrate --prisma --sql --output ./migration npx lsdb migrate --sql --apply --connection-string postgres://user:pass@host/db npx lsdb migrate --sql --apply --driver mysql --dry-run npx lsdb migrate --prisma --apply ``` ### `lsdb migrate-data [--table <name>] [--all-users] [--output <dir>] [--dry-run] [--run] [--connection-string <url>] [--driver <driver>] [--token-file <path>]` Without `--run`: generates a `migrate-data.js` script that reads row data from Google Sheets and calls a stub `insertRow()` function. Replace the stub with your real DB client (Prisma, Sequelize, etc.) to move data. With `--run`: skips the generated script and executes the same admin-then-per-user traversal immediately, upserting every row into a real `createPostgresAdapter`/`createMySQLAdapter` target by `_id` (unconditionally idempotent — safe to rerun from CI without a separate `--upsert` flag). `--driver prisma` is not supported for `--run` — there's no way for a CLI process to construct a consumer's typed `PrismaClient`; use `--driver postgres`/`mysql` for the one-time cutover regardless of which client the app uses long-term (see [FAQ.md §13](./FAQ.md#13-sql-backend-portability--tenancy)). > **Actors vs RBAC roles** — see [Actors vs Application Roles](#actors-vs-application-roles) below. **Flags:** - `--table <name>` — migrate a single table only - `--all-users` — also reads every registered user's `actor_sheet_id` from the admin `users` table and migrates their actor sheets. Without `--run`, the generated script includes a per-user loop with `userId` passed to `insertRow`; with `--run`, each user's rows are upserted under their own tenant-scoped context. - `--output <dir>` — output directory for the generated script (default: current directory; ignored with `--run`) - `--dry-run` — without `--run`, preview the export plan without writing a file; with `--run`, print row counts without writing anything - `--run` — execute the cutover now, in-process, instead of generating a script - `--connection-string <url>` — target DB for `--run` (falls back to `$DATABASE_URL`) - `--driver <postgres|mysql>` — inferred from the connection string when omitted - `--token-file <path>` — pre-stored OAuth tokens for `--run`'s Sheets read side, skips interactive login (CI-friendly, same convention as `sync --token-file`) ```bash npx lsdb migrate-data npx lsdb migrate-data --all-users npx lsdb migrate-data --all-users --dry-run npx lsdb migrate-data --table bookings npx lsdb migrate-data --run --connection-string $DATABASE_URL --driver postgres npx lsdb migrate-data --run --all-users --driver mysql --token-file token.json ``` > **Deprecated aliases**: `lsdb export` and `lsdb export-data` still work but emit a deprecation warning — they forward to `lsdb migrate` and `lsdb migrate-data` respectively. In standard tooling (Prisma Migrate, Rails, Flyway), "migrate" means schema-only DDL changes, which is why the schema/DDL export command is named `migrate`; the row-data export command is `migrate-data`. ### Migration scenarios | 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` | ### `lsdb drop-table [table-names...] [--all-users] [--yes] [--dry-run] [--token-file <path>]` Deletes table schema file(s) and the corresponding Google Sheet tab(s). `sync` never removes anything on its own — this is the explicit, confirmed way to remove a table from both the codebase and the live sheet(s). - No positional args — interactive checkbox over every defined table (space to toggle, enter to confirm) - Positional args — one or more `tableName` or `actor/tableName` (use the `actor/` form when the same table name exists under more than one actor) - `--all-users` — also drops the tab from every registered user's personal sheet (reads `actor_sheet_id` from the admin `users` table) - `--yes` — skip the confirmation prompt - `--dry-run` — print the plan without making any changes - `--token-file <path>` — CI/CD, same as `sync --token-file` ```bash npx lsdb drop-table bookings npx lsdb drop-table bookings old_notifications --all-users npx lsdb drop-table --dry-run npx lsdb drop-table # interactive ``` Invalid table names produce a clear error (with a "did you mean" suggestion when close to a real name) instead of silently doing nothing. Warns if another table's `ref()` points at a table being dropped, since it doesn't rewrite `ref()` strings in other schema files. ### `lsdb drop-column [table-name] [column-names...] [--all-users] [--yes] [--dry-run] [--token-file <path>]` Removes column(s) from a table's schema file and deletes the matching column(s) from the live Google Sheet, including all data in them. - Table omitted — interactive single-select list - Column names omitted — interactive checkbox over that table's columns - Refuses to drop reserved auto-generated columns (`_id`, `_created_at`, `_updated_at`, `_deleted_at`) or the table's primary key column (drop the whole table instead) - Resolves each column's *live* position in the sheet's current header row before deleting — not the schema file's declared order, since `sync` appends new columns at the end - Same `--all-users`/`--yes`/`--dry-run`/`--token-file` flags as `drop-table` ```bash npx lsdb drop-column bookings notes npx lsdb drop-column bookings # interactive column checkbox npx lsdb drop-column # interactive table + column ``` ### `lsdb rename-column [table-name] [old-name] [new-name] [--all-users] [--yes] [--dry-run] [--token-file <path>]` Renames a column in the schema file and overwrites the corresponding Google Sheet **header cell in place** — existing row data is preserved, unlike a drop-and-re-add. This is the safe way to fix a column name without losing data the next time the sheet syncs or a mismatch triggers `auto-sync`. - Any of `table-name`/`old-name`/`new-name` omitted — prompts interactively (list, list, then text input) - New name must be a valid identifier (`^[a-zA-Z_][a-zA-Z0-9_]*$`), not already used on the table, and not a reserved name - Warns (doesn't block) if another table's `ref()` points at `table.oldName` — those `ref()` strings aren't rewritten automatically - Same `--all-users`/`--yes`/`--dry-run`/`--token-file` flags as `drop-table` ```bash npx lsdb rename-column bookings notes remarks npx lsdb rename-column # fully interactive ``` ### `lsdb erdiagram [--output <file>] [--yes]` Generates a Markdown file containing a Mermaid `erDiagram` of every registered table and its `ref()` relationships — a quick visual map of the schema, viewable directly on GitHub/GitLab or in any Mermaid-aware Markdown viewer. Offline — reads schema files only, no Google Sheets/OAuth calls. - Entities list every column with its type and, where applicable, a key marker: `PK` (primary key), `FK` (`ref()` column), or `UK` (`unique()` column) — `PK` takes priority over `FK`/`UK` on the same column - Relationship lines connect the referenced table to the referencing table, labeled with the FK column name; cardinality is `||--||` (one-to-one) when the FK column is also `unique()`, otherwise `||--o{` (one-to-many) - A `ref()` pointing at a table not registered on this adapter is skipped from the diagram rather than emitting a broken relationship line - `--output <file>` — output file path (default: `ER-DIAGRAM.md` in the current directory) - `--yes` — overwrite an existing output file without prompting ```bash npx lsdb erdiagram npx lsdb erdiagram --output docs/schema.md npx lsdb erdiagram --yes ``` If the target file already exists and `--yes` isn't passed, prompts to overwrite, save under a different name (looped until you pick a free/confirmed name), or cancel. ### `lsdb doctor` Health check: validates env vars, config file, OAuth tokens, and schema directory. ### `lsdb status` Displays project status: actor list, env var values, OAuth token state, and all registered tables with column counts. ## Cross-Actor Operations Cross-actor operations allow one actor (e.g. teacher) to perform CRUD on another actor's sheet (e.g. student), subject to a permission matrix. ### Permission Matrix Configuration ```typescript const adapter = createSheetAdapter({ adminSheetId: process.env.ADMIN_SHEET_ID, credentials: { clientId, clientSecret, redirectUri }, tokens: oauthTokens, permissions: { teacher: { canAccess: ['student'], tables: ['scores', 'attendance'], // omit to allow all tables }, }, }); ``` ### Cross-Actor Context ```typescript // Option A: withContext with targetActor + targetSheetId const ctx = adapter.withContext({ userId: 'teacher_001', actor: 'teacher', actorSheetId: 'teacher-sheet-id', targetActor: 'student', targetSheetId: 'student-sheet-id-123', }); // Option B: asActor() shorthand const ctx = adapter .withContext({ userId: 'teacher_001', actor: 'teacher', actorSheetId: 'teacher-sheet-id' }) .asActor('student', 'student-sheet-id-123'); // All CRUD operations now target the student sheet await ctx.table('scores').create({ student_id: 'stu_456', score: 95 }); const scores = await ctx.table('scores').findMany({ where: { student_id: 'stu_456' } }); await ctx.table('scores').update({ where: { _id: 'score_001' }, data: { score: 98 } }); await ctx.table('scores').delete({ where: { _id: 'score_001' } }); ``` ### Permission Rules | Scenario | Behaviour | |---|---| | Same actor access | Always allowed | | Admin access | Bypasses all permission checks | | Cross-actor — role in `permissions.canAccess` | Allowed | | Cross-actor — role not in `permissions` | `PermissionError` | | Cross-actor — table not in `permissions.tables` | `PermissionError` | | Cross-actor — `targetSheetId` missing | `PermissionError` | --- ## Type Definitions ### `DatabaseAdapter` The formal contract every storage engine implements (`SheetAdapter`, and the [SQL adapters](#sql-adapters)) — `src/adapter/types.ts`. Implement this to back `adapter.table(name)` with a storage engine of your own without reaching into internals. ```typescript interface DatabaseAdapter { withContext(context: UserContext): DatabaseAdapter; asActor(targetActor: string, targetSheetId: string): DatabaseAdapter; table(tableName: string): TableOperations; } ``` ### `TableOperations` `CRUDOperations`' public shape, extracted so a non-Sheets storage engine can back `adapter.table(name)` with its own implementation. ```typescript interface TableOperations { create(data: Record<string, unknown>, options?: CreateOptions): Promise<Record<string, unknown>>; createMany(records: Record<string, unknown>[], options?: CreateOptions): Promise<Record<string, unknown>[]>; findMany(options?: FindOptions): Promise<Record<string, unknown>[]>; findOne(options?: FindOptions): Promise<Record<string, unknown> | null>; update(options: UpdateOptions): Promise<number>; upsert(options: UpsertOptions): Promise<Record<string, unknown>>; delete(options: DeleteOptions): Promise<number>; count(options?: Pick<FindOptions, 'where' | 'includeDeleted'>): Promise<number>; } ``` ### `StorageClient` The subset of `SheetClient` that `CRUDOperations` actually depends on — decoupling it from the concrete class means a SQL-backed equivalent can implement this interface directly instead of `SheetClient`. `extendValidation` is optional: it re-applies Sheets checkbox/dropdown validation ranges and has no equivalent concept in a SQL engine. ```typescript interface StorageClient { getAllRows(spreadsheetId: string, sheetName: string): Promise<string[][]>; appendRow(spreadsheetId: string, sheetName: string, values: string[]): Promise<number>; appendRows(spreadsheetId: string, sheetName: string, rows: string[][]): Promise<void>; updateRow(spreadsheetId: string, sheetName: string, rowIndex: number, values: string[]): Promise<void>; deleteRow(spreadsheetId: string, sheetName: string, rowIndex: number): Promise<void>; writeHeader(spreadsheetId: string, sheetName: string, headers: string[]): Promise<void>; extendValidation?(spreadsheetId: string, sheetName: string, rules: ColumnValidationRule[], dataRowCount: number): Promise<void>; } ``` ### `TableSchema` ```typescript interface TableSchema { name: string; actor: string; timestamps?: boolean; softDelete?: boolean; columns: Record<string, ColumnDefinition>; pkColumn?: string; // set automatically when primary() is used } ``` ### `ColumnDefinition` ```typescript interface ColumnDefinition { type: 'string' | 'number' | 'boolean' | 'date' | 'json'; required?: boolean; unique?: boolean; default?: JsonValue; // string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } — needed for json() column defaults min?: number; max?: number; enum?: (string | number | boolean)[]; pattern?: RegExp; readonly?: boolean; primary?: boolean; // auto-generates nanoid for string columns ref?: string; // 'table.column' FK reference index?: boolean; booleanFormat?: BooleanFormat; // boolean() columns only — overrides the project-wide sheetStyle default } ``` ### `BooleanFormat` ```typescript type BooleanFormat = 'TRUE_FALSE' | '1_0'; ``` Value pair a `boolean()` column reads/writes as. Configurable project-wide via [`SheetStyleConfig.booleanFormat`](#sheetstyleconfig) (default `'TRUE_FALSE'`) or per-column via `boolean({ format })`, which takes priority when set. ### `UserContext` ```typescript interface UserContext { userId: string; /** Preferred. The data domain (actor) for this operation — e.g. 'admin', 'seller', 'student'. */ actor?: string; /** @deprecated Use actor instead. Accepted for backward compat; emits console.warn when used. */ role?: string; actorSheetId?: string; /** Preferred. Cross-actor: which actor's sheet to access. */ targetActor?: string; /** @deprecated Use targetActor instead. Accepted for backward compat; emits console.warn when used. */ targetRole?: string; targetSheetId?: string; // cross-actor: the target actor's sheet ID } ``` > **actor vs role**: `actor` is the lsdb concept of a *data domain* (which Google Sheet, which schemas). It is **not** an application-level RBAC role. If your app has dynamic permissions (admin/manager/viewer), build those in a `roles` + `role_permissions` table and enforce them in your own middleware — lsdb intentionally does not provide RBAC. ### Actors vs Application Roles | Concept | Controls | Dynamic? | Where defined | |---------|----------|----------|---------------| | **Actor** (`actor:`) | Which Google Sheet + table schemas to use | No — fixed in `lsdb.config.ts` | Config file | | **App RBAC role** | What a user is allowed to do in your app | Yes — rows in your app's DB | Your app layer | Every field that identifies an actor follows this naming, so it can't be mistaken for an RBAC role at the point of writing the code: | Location | Preferred | Deprecated alias | |---|---|---| | `ActorConfig` (`lsdb.config.ts`) | `name` | `role` | | `UserContext` (`withContext()`) | `actor` | `role` | | `UserContext` cross-actor target | `targetActor` | `targetRole` | ### `ActorPermission` ```typescript interface ActorPermission { canAccess: string[]; // actor roles this role can access tables?: string[]; // restrict to specific tables (omit = all tables) } ``` ### `ActorConfig` ```typescript interface ActorConfig { /** Preferred. The actor's identifier — e.g. 'admin', 'seller', 'student'. */ name?: string; /** @deprecated Use name instead. Accepted for backward compat; emits console.warn when used. */ role?: string; sheetIdEnv: string; // env var name that holds this actor's sheet ID } ``` ### `SheetStyleConfig` ```typescript interface SheetStyleConfig { headerColor?: string; // hex color for the header row fill, e.g. '#E8F0FE' freezeHeader?: boolean; // default: true freezeFirstColumn?: boolean; // default: false booleanFormat?: BooleanFormat; // 'TRUE_FALSE' | '1_0' — default: 'TRUE_FALSE' } ``` Passed as `sheetStyle` on `createSheetAdapter()`. Auto-fit column width and `boolean()`/`enum()` data validation dropdowns are always applied — no config needed. `booleanFormat` is the project-wide default for every `boolean()` column; override an individual column with `boolean({ format })` (see [`boolean()`](#boolean)). ### `SheetReadCacheConfig` ```typescript interface SheetReadCacheConfig { enabled?: boolean; // default: true ttlMs?: number; // default: 2000 (2s) } ``` Passed as `cache` on `createSheetAdapter()`. Bounds and de-duplicates `values.get` calls made through `SheetClient.getAllRows()` — see the read caching note under [CRUD Operations](#crud-operations) and FAQ.md #11. ```typescript const adapter = createSheetAdapter({ // ... cache: { ttlMs: 5000 }, // widen the window for a read-heavy admin dashboard // cache: { enabled: false } // disable entirely (not recommended — see FAQ.md #11) }); ``` The cache is per-`SheetClient` instance and per-process — it does not know about writes made outside the adapter (a human editing the sheet directly, or another process/server instance sharing the same spreadsheet). `ttlMs` bounds how stale those external changes can appear; call `adapter.getClient().invalidateCache(spreadsheetId, sheetName)` to force a specific tab to refetch immediately. ### `SchemaMismatchBehaviour` ```typescript type SchemaMismatchBehaviour = 'warn' | 'error' | 'auto-sync'; ``` Configured via `onSchemaMismatch` in `createSheetAdapter()`: - `'warn'` — logs to stderr, continues (default) - `'error'` — throws `SchemaMismatchError` - `'auto-sync'` — syncs the actor sheet before proceeding ### `OAuthTokens` ```typescript interface OAuthTokens { access_token?: string | null; refresh_token?: string | null; expiry_date?: number | null; id_token?: string | null; token_type?: string | null; scope?: string | null; } ``` ### `TokenStore` ```typescript interface TokenStore { get(actorId: string): Promise<OAuthTokens | null>; set(actorId: string, tokens: OAuthTokens): Promise<void>; } ``` Passed as `tokenStore` to `createSheetAdapter`. The adapter calls `get(userId)` in `createUserSheet` when `actorTokens` is not provided directly. Callers are responsible for calling `set(userId, tokens)` after each Google OAuth callback. ### `DriveFolderConfig` ```typescript interface DriveFolderConfig { root: string; // folder name at Drive root; created if missing subfolders?: Record<string, string>; // actor role -> subfolder name (defaults to role name) } ``` ### `UploadOptions` ```typescript interface UploadOptions { filename: string; mimeType: string; folder?: string; // subfolder path relative to driveFolder.root; created if missing public?: boolean; // when true, sets Drive permission: anyone / reader } ``` ### `StorageAdapter` ```typescript interface StorageAdapter { upload(file: Buffer, options: UploadOptions): Promise<string>; // returns public URL delete(url: string): Promise<void>; } ``` ### `CreateUserSheetOptions` ```typescript interface CreateUserSheetOptions { actorTokens?: OAuthTokens; // create sheet in actor's Drive when provided extraFields?: Record<string, unknown>; // extra columns merged into the users table row } ``` ### `DriveStorageAdapter` ```typescript class DriveStorageAdapter implements StorageAdapter { constructor(options?: { folder?: string }); // default folder: 'uploads' upload(file: Buffer, options: UploadOptions): Promise<string>; delete(url: string): Promise<void>; } ``` Built-in `StorageAdapter` implementation that uploads to Google Drive and returns a `https://drive.google.com/uc?id=…` URL. The adapter's `SheetClient` is injected automatically at `createSheetAdapter()` time — no credential repetition needed. **Example:** ```typescript import { DriveStorageAdapter } from 'longcelot-sheet-db'; const adapter = createSheetAdapter({ adminSheetId: process.env.ADMIN_SHEET_ID, credentials: { ... }, tokens: adminTokens, driveFolder: { root: 'My App', subfolders: { seller: 'Sellers' } }, sharedDriveId: process.env.SHARED_DRIVE_ID, // optional tokenStore: myTokenStore, // optional storage: new DriveStorageAdapter({ folder: 'uploads' }), }); ```