UNPKG

longcelot-sheet-db

Version:

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

198 lines (151 loc) 8.38 kB
--- name: schema description: Define tables and columns for longcelot-sheet-db using defineTable() and the fluent column builder API. Use when creating or modifying schema files, adding columns, configuring timestamps/soft-delete, setting up primary keys with auto-generation, adding foreign key references with runtime enforcement, or understanding column modifiers like required, unique, enum, default, ref, and index. license: MIT metadata: package: longcelot-sheet-db version: "0.1.15" --- # longcelot-sheet-db — Schema Definition Schemas are the primary contract between your code and Google Sheets. Each `defineTable()` call produces one sheet (tab) inside a Google Spreadsheet. ## defineTable() ```typescript import { defineTable, string, number, boolean, date, json } from 'longcelot-sheet-db'; export default defineTable({ name: 'bookings', // Sheet tab name — must be unique per actor actor: 'user', // Which role owns this table ('admin' | your custom actors) timestamps: true, // Adds _created_at, _updated_at columns softDelete: true, // Adds _deleted_at; delete() sets it instead of removing the row columns: { booking_id: string().primary(), // PK: auto-generates nanoid if omitted on create service: string().required(), date: date().required(), status: string().enum(['pending', 'confirmed', 'cancelled']).default('pending'), price: number().min(0), notes: string(), user_id: string().ref('users._id'), // FK: validated at runtime on create/update }, }); ``` ### Auto-generated columns These are always present and must NOT be defined manually: | Column | Always present | Requires option | |---|---|---| | `_id` | ✅ (nanoid) | — | | `_created_at` | ✅ when `timestamps: true` | `timestamps: true` | | `_updated_at` | ✅ when `timestamps: true` | `timestamps: true` | | `_deleted_at` | ✅ when `softDelete: true` | `softDelete: true` | --- ## Column Builders Import individual builders from `longcelot-sheet-db`: ```typescript import { string, number, boolean, date, json } from 'longcelot-sheet-db'; ``` | Builder | Stored as | Notes | |---|---|---| | `string()` | Plain text | | | `number()` | Numeric text | Deserialized to `Number` on read | | `boolean()` | `"TRUE"`/`"FALSE"` (or `"1"`/`"0"` — see `format` below) | Deserialized to `true`/`false` on read; both pairs accepted regardless of configured format | | `date()` | ISO 8601 string | Accepts a `Date` instance or an ISO string on write, both normalized to the same clean ISO string (see FAQ.md #10) — always deserialized back to a plain ISO string, never a `Date` instance | | `json()` | JSON string | Serialized with `JSON.stringify`, parsed on read | ### `boolean()` value pair `boolean()` renders as a dropdown (`ONE_OF_LIST`, not a native checkbox — see FAQ.md #10) restricted to `TRUE`/`FALSE` by default. Override per column: ```typescript columns: { legacy_flag: boolean({ format: '1_0' }), // this column only active: boolean(), // falls back to sheetStyle.booleanFormat } ``` Or project-wide via `sheetStyle.booleanFormat` on `createSheetAdapter()` (default `'TRUE_FALSE'`) — see `skills/core/SKILL.md`. The per-column `format` always wins when both are set. --- ## Column Modifiers (Fluent Chain) All modifiers return `this` — chain them freely: ```typescript string().required().unique().min(5).max(200) number().min(0).max(100).default(50) string().enum(['active', 'inactive']).default('active') string().pattern(/^[a-z0-9-]+$/) string().readonly() string().primary() string().ref('users._id') string().index() ``` ### Full modifier reference | Modifier | Applies to | Runtime effect | |---|---|---| | `.required()` | all | Throws `ValidationError` if `null`/`undefined` on `create()` | | `.unique()` | all | Throws `ValidationError` if value already exists in the column | | `.default(value)` | all | Applied when field is omitted on `create()` — never on `update()`. Accepts arrays/objects too, e.g. `json().default([])` | | `.min(n)` | string, number | Min length (string) or min value (number) | | `.max(n)` | string, number | Max length (string) or max value (number) | | `.enum([...])` | string | Throws `ValidationError` if value not in list | | `.pattern(regex)` | string | Throws `ValidationError` if value doesn't match | | `.readonly()` | all | Throws `ValidationError` if included in `update()` data | | `.primary()` | string, number | **String PK**: auto-generates nanoid on `create()` if omitted. **Number PK**: developer must supply. Only one `primary()` column allowed per table. PK is silently stripped from `update()` data. | | `.ref('table.col')` | string | **FK enforcement**: on `create()` and `update()`, reads the referenced table and throws `ValidationError` if the value doesn't exist. Pass `{ skipFKValidation: true }` to bypass. | | `.index()` | all | Metadata marker — index support planned | --- ## primary() Behavior (PK auto-generation) ```typescript // string PK — value is auto-generated if not supplied const record = await ctx.table('orders').create({ customer_id: 'cust_001', total: 99.99, // order_id is omitted — nanoid is generated automatically }); // record.order_id = 'V1StGXR8_Z5j...' (nanoid) // number PK — developer MUST supply the value const record = await ctx.table('counters').create({ counter_id: 42, // required — cannot be auto-generated for numbers label: 'views', }); ``` - Only **one** `primary()` column is allowed per table; `defineTable()` throws `SchemaError` if more than one is found. - The PK column is **always read-only** on `update()` — it is silently stripped from the data. --- ## ref() Behavior (FK runtime enforcement) ```typescript // If 'users._id' does not contain 'user_999', this throws: // ValidationError: FK violation: users._id 'user_999' does not exist await ctx.table('bookings').create({ user_id: 'user_999', // ref('users._id') — validated at runtime service: 'Consultation', }); // Skip FK validation for bulk seed operations: await ctx.table('bookings').create(data, { skipFKValidation: true }); ``` Both the referenced table and column must be registered with `adapter.registerSchemas()`. Circular `ref()` chains throw `SchemaError: Circular reference detected` at registration time. --- ## Actor System The `actor` field in `defineTable()` controls which Google Sheet stores the data: - `actor: 'admin'` → data lives in `adminSheetId` (central admin sheet) - `actor: 'user'` (or any custom role) → data lives in the user's personal `actorSheetId` ```typescript // Admin-owned: lives in the central admin spreadsheet export default defineTable({ name: 'users', actor: 'admin', ... }); // User-owned: lives in each user's personal sheet export default defineTable({ name: 'profile', actor: 'user', ... }); ``` --- ## File Naming Conventions ``` schemas/ ├── admin/ │ ├── users.ts │ ├── credentials.ts │ └── schema_versions.ts # scaffolded by init — do not modify └── user/ ├── profile.ts └── bookings.ts ``` - File name: `snake_case` matching the table `name` - Use `export default` for each schema file - Organize by actor inside `schemas/` --- ## Common Mistakes - **Defining `_id`, `_created_at`, `_updated_at`, or `_deleted_at` manually** — These are auto-generated; including them in `columns` causes schema validation errors. - **Duplicate table names across actors** — `name` must be unique **per actor**, not globally. Two actors can each have a `profile` table. - **More than one `primary()` column** — `defineTable()` throws `SchemaError: only one primary() allowed per table`. - **`ref()` without registering the referenced schema** — If `user_id` references `users._id` but `users` is not registered via `registerSchemas()`, FK validation throws `SchemaError: Referenced table 'users' is not registered`. - **Using `softDelete: true` and expecting hard deletes** — With `softDelete` enabled, `table.delete()` sets `_deleted_at` and leaves the row. Use `table.findMany()` — soft-deleted rows are automatically excluded. - **`actor` mismatch in `withContext()`** — If you call `withContext({ role: 'user' })` but access a table with `actor: 'admin'`, a `PermissionError` is thrown.