longcelot-sheet-db
Version:
Google Sheets-backed staging database adapter for Node.js with schema-first design
198 lines (151 loc) • 8.38 kB
Markdown
---
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.