@kardoe/quickback 0.5.8 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/compile.d.ts.map +1 -1
- package/dist/commands/compile.js +19 -13
- package/dist/commands/compile.js.map +1 -1
- package/dist/docs/content.d.ts.map +1 -1
- package/dist/docs/content.js +58 -13
- package/dist/docs/content.js.map +1 -1
- package/dist/lib/api-client.d.ts +2 -12
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js.map +1 -1
- package/dist/lib/compiler-stubs.d.ts +5 -0
- package/dist/lib/compiler-stubs.d.ts.map +1 -1
- package/dist/lib/compiler-stubs.js +7 -0
- package/dist/lib/compiler-stubs.js.map +1 -1
- package/dist/lib/file-loader.d.ts +25 -0
- package/dist/lib/file-loader.d.ts.map +1 -1
- package/dist/lib/file-loader.js +36 -6
- package/dist/lib/file-loader.js.map +1 -1
- package/package.json +1 -1
- package/src/skill/SKILL.md +6 -6
package/dist/docs/content.js
CHANGED
|
@@ -69,6 +69,42 @@ export const DOCS = {
|
|
|
69
69
|
"title": "Changelog",
|
|
70
70
|
"content": "# Changelog\n\nRelease notes for the Quickback compiler, CLI, and platform.\n\n---\n\n## v0.5.7 — February 12, 2026\n\n### Scoped Database for Actions\n\nActions now receive a **security-scoped database** instead of a raw Drizzle instance. The compiler generates a proxy wrapper that automatically enforces org isolation, owner filtering, and soft-delete visibility — the same protections that CRUD routes have always had.\n\nThe scoped DB uses duck-typed column detection at runtime:\n\n| Column Detected | SELECT / UPDATE / DELETE | INSERT |\n|---|---|---|\n| `organizationId` | Adds `WHERE organizationId = ?` | Auto-injects `organizationId` from context |\n| `ownerId` | Adds `WHERE ownerId = ?` | Auto-injects `ownerId` from context |\n| `deletedAt` | Adds `WHERE deletedAt IS NULL` | — |\n\nThis means **every action is secure by default** — no manual `WHERE` clauses needed.\n\n```typescript\ndefineActions(todos, {\n complete: {\n type: \"record\",\n execute: async ({ db, ctx, record, input }) => {\n // db is scoped — only sees records in user's org, excludes soft-deleted\n const siblings = await db.select().from(todos);\n // ↑ automatically filtered to ctx.activeOrgId + deletedAt IS NULL\n },\n },\n});\n```\n\n#### Unsafe Mode\n\nActions that intentionally need to bypass security (admin reports, cross-org queries, migrations) can declare `unsafe: true` to receive a raw, unscoped database handle:\n\n```typescript\ndefineActions(analytics, {\n globalReport: {\n unsafe: true,\n execute: async ({ db, rawDb, ctx, input }) => {\n // db → still scoped (safety net)\n // rawDb → bypasses all security filters\n const allOrgs = await rawDb.select().from(organizations);\n },\n },\n});\n```\n\nWithout `unsafe: true`, `rawDb` is `undefined`.\n\n**Related docs:** [Actions](/compiler/definitions/actions), [Actions API](/compiler/using-the-api/actions-api)\n\n---\n\n### Cascading Soft Delete\n\nSoft-deleting a parent record now **automatically cascades** to child and junction tables within the same feature. The compiler detects foreign key references at build time and generates cascade UPDATE statements.\n\n```\nDELETE /api/v1/projects/:id\n```\n\nGenerated behavior:\n```typescript\n// 1. Soft delete the parent\nawait db.update(projects)\n .set({ deletedAt: now, deletedBy: userId })\n .where(eq(projects.id, id));\n\n// 2. Auto-cascade to children (compiler-generated)\nawait db.update(projectMembers)\n .set({ deletedAt: now, deletedBy: userId })\n .where(eq(projectMembers.projectId, id));\n\nawait db.update(projectTasks)\n .set({ deletedAt: now, deletedBy: userId })\n .where(eq(projectTasks.projectId, id));\n```\n\nRules:\n- Only applies to **soft delete** (the default). Hard delete relies on database-level `ON DELETE CASCADE`.\n- Only cascades within the **same feature** — cross-feature references are not affected.\n- Child tables must have `deletedAt` / `deletedBy` columns (auto-added by the compiler's audit fields).\n\n**Related docs:** [Actions API — Cascading Soft Delete](/compiler/using-the-api/actions-api#cascading-soft-delete)\n\n---\n\n### Advanced Query Parameters\n\nNew query parameter capabilities for all list endpoints:\n\n- **Field selection** — `?fields=id,name,status` returns only the columns you need\n- **Multi-sort** — `?sort=status:asc,createdAt:desc` sorts by multiple fields\n- **Total count** — `?count=true` returns total matching records in response headers (`X-Total-Count`)\n- **Full-text search** — `?search=keyword` searches across all text columns\n\n```bash\n# Get only names and statuses, sorted by status then date, with total count\nGET /api/v1/todos?fields=id,name,status&sort=status:asc,createdAt:desc&count=true\n\n# Search across all text fields\nGET /api/v1/todos?search=urgent\n```\n\n**Related docs:** [Query Parameters](/compiler/using-the-api/query-params)\n\n---\n\n### Audit Field Improvements\n\n- `deletedAt` and `deletedBy` fields are now **always injected** by the compiler for tables with soft delete enabled — no need to define them in your schema\n- All audit fields (`createdAt`, `createdBy`, `modifiedAt`, `modifiedBy`, `deletedAt`, `deletedBy`) are auto-managed\n\n---\n\n## v0.5.6 — February 8, 2026\n\n### Database Naming Conventions\n\n- Default table and column naming changed to **snake_case** with `usePlurals: false`\n- Table names derived from generated Better Auth schema for consistency\n- Removed legacy single-database mode — split databases (auth + features) is now the standard\n\n### Auth Variable Shadowing Fix\n\n- Fixed `member` variable in auth middleware that shadowed the Drizzle `member` table import\n- Renamed to `sessionMember` to avoid conflicts in generated routes\n\n---\n\n## v0.5.5 — February 5, 2026\n\n### Better Auth Plugins\n\n- Published `@kardoe/better-auth-upgrade-anonymous` v1.1.0 — post-passkey email collection flow\n- Published `@kardoe/better-auth-combo-auth` — combined email + password + OTP authentication\n- Published `@kardoe/better-auth-aws-ses` — AWS SES email provider for Better Auth\n\n### OpenAPI Spec Generation\n\n- Generated APIs now include a full OpenAPI specification at `/openapi.json`\n- Better Auth endpoints included in the spec\n- Runtime route: `GET /openapi.json`\n\n### Security Hardening\n\n- Global error handler prevents leaking internal error details\n- Security headers middleware (CSP, HSTS, X-Frame-Options, X-Content-Type-Options)\n- `BETTER_AUTH_SECRET` properly passed to generated config\n\n---\n\n## v0.5.4 — January 30, 2026\n\n### Account UI\n\n- Pre-built authentication UI deployed as Cloudflare Workers\n- Features: sessions, organizations, passkeys, passwordless, admin panel, API keys\n- Dual-mode: standalone (degit template) or embedded with Quickback projects\n\n### Webhook System\n\n- Inbound webhook endpoints with signature verification\n- Outbound webhooks via Cloudflare Queues with automatic retries\n- Configurable per-feature webhook events\n\n### Realtime & Vector Search\n\n- Durable Objects + WebSocket realtime subscriptions\n- Vector embeddings via Cloudflare Vectorize\n- KV and R2 storage integrations\n\n---\n\n## v0.5.0 — January 2026\n\n### Initial Release\n\n- **Quickback Compiler** — TypeScript-first backend compiler\n- **Four Security Pillars** — Firewall, Access, Guards, Masking\n- **Combined Mode** — `defineTable()` with schema + security in a single file\n- **Templates** — Cloudflare Workers, Bun standalone, B2B SaaS\n- **Cloud Compiler** — Remote compilation via `compiler.quickback.dev`\n- **CLI** — `quickback create`, `quickback compile`, `quickback init`\n- **Better Auth Integration** — Organizations, roles, sessions\n- **Drizzle ORM** — Schema-first with automatic migrations\n- **Cloudflare D1** — Split database support (auth + features)"
|
|
71
71
|
},
|
|
72
|
+
"cms/actions": {
|
|
73
|
+
"title": "Actions",
|
|
74
|
+
"content": "# Actions\n\nActions are custom operations defined in your feature files (e.g., `approve`, `void`, `post`, `applyPayment`). The CMS renders them as clickable buttons with auto-generated input forms — no UI code required.\n\n## Where Actions Appear\n\nActions show up in two places:\n\n### Row Action Menu (Table Mode)\n\nIn Table mode, the three-dot menu on each row includes a section for custom actions. Actions are separated from standard operations (view, edit, delete) by a divider.\n\n### Action Bar (Detail View)\n\nIn the record detail view, an \"Actions\" card displays all available actions as buttons. Each button shows the action name, and hovering reveals the description as a tooltip.\n\n## Action Dialog\n\nClicking an action opens a modal dialog with:\n\n1. **Header** — Action name with an icon (lightning bolt for standard, warning triangle for destructive, download for file responses)\n2. **Description** — The action's description text\n3. **Side effects warning** — If the action has `sideEffects: \"sync\"`, an amber warning banner appears: \"This action has synchronous side effects (e.g., GL entries).\"\n4. **Confirmation step** — If the action has `cms.confirm`, a confirmation message appears before the input form\n5. **Input fields** — Auto-generated from the action's `inputFields` schema\n6. **Execute button** — Submits the action. Shows \"Executing...\" while in progress.\n7. **Cancel button** — Closes the dialog without executing\n\n### Input Field Types\n\nThe dialog generates the appropriate input control for each field:\n\n| Zod Type | Input Control | Notes |\n|----------|--------------|-------|\n| `string` | Text input | Free-text entry |\n| `number` | Number input | Step 0.01, supports decimals |\n| `boolean` | Checkbox | With label text |\n| `array<string>` | Text input | Comma-separated values, split on save |\n\nRequired fields are marked with a red asterisk. Default values from the schema are pre-filled.\n\n### Example\n\nGiven this action definition:\n\n```typescript\nactions: {\n applyPayment: {\n description: \"Apply a payment to this invoice\",\n input: 'z.object({ amount: z.number(), reference: z.string().optional() })',\n access: {\n roles: ['admin', 'owner'],\n record: { status: { equals: 'posted' } },\n },\n sideEffects: \"sync\",\n cms: {\n label: \"Apply Payment\",\n icon: \"dollar-sign\",\n confirm: \"This will create GL entries. Continue?\",\n category: \"payments\",\n successMessage: \"Payment applied successfully\",\n order: 1,\n },\n },\n}\n```\n\nThe CMS renders a dialog with:\n\n- A number input for `amount` (required)\n- A text input for `reference` (optional)\n- A sync side effects warning\n- A confirmation step with the custom message\n- Only visible to admins and owners\n- Only visible on records where `status === \"posted\"`\n\n## Access Filtering\n\nActions are filtered based on two criteria:\n\n### Role Check\n\nThe action's `access.roles` array is compared against the current user's role. If the user's role is not in the list, the action is hidden.\n\n### Record Condition\n\nThe action's `access.record` conditions are evaluated against the current record's data. Supported conditions:\n\n| Operator | Example | Meaning |\n|----------|---------|---------|\n| `equals` | `{ status: { equals: 'pending' } }` | Field must equal value |\n| `notEquals` | `{ status: { notEquals: 'void' } }` | Field must not equal value |\n| `in` | `{ status: { in: ['pending', 'draft'] } }` | Field must be one of values |\n\nAll conditions must pass for the action to be visible. This means \"approve\" actions naturally disappear after a record is approved, and \"void\" actions only appear on voidable records.\n\n## CMS Metadata\n\nActions can include an optional `cms` property for enhanced CMS rendering. Actions without `cms` render with default behavior.\n\n| Property | Type | Default | Purpose |\n|----------|------|---------|---------|\n| `label` | `string` | camelCase split | Display name override |\n| `icon` | `string` | `\"zap\"` | Lucide icon name |\n| `confirm` | `string \\| boolean` | `false` | `true` = generic confirm, string = custom message |\n| `destructive` | `boolean` | `false` | Red styling + requires confirmation |\n| `category` | `string` | none | Group actions under a header |\n| `hidden` | `boolean` | `false` | Hide from CMS entirely (API-only actions) |\n| `successMessage` | `string` | none | Toast message after success |\n| `onSuccess` | `\"refresh\" \\| \"redirect:list\" \\| \"close\"` | `\"refresh\"` | Behavior after successful execution |\n| `order` | `number` | `0` | Sort priority (lower = first) |\n\n### Example with CMS metadata\n\n```typescript\npost: {\n description: \"Post this invoice to accounts receivable\",\n input: z.object({}),\n access: { roles: [\"owner\", \"admin\"], record: { status: { equals: \"draft\" } } },\n handler: \"./handlers/post\",\n sideEffects: \"sync\",\n cms: {\n label: \"Post Invoice\",\n icon: \"send\",\n confirm: \"This will post the invoice to AR and create GL entries. Continue?\",\n category: \"lifecycle\",\n successMessage: \"Invoice posted successfully\",\n onSuccess: \"refresh\",\n order: 1,\n },\n},\n```\n\n### Hiding API-only actions\n\n```typescript\ninternalRecalc: {\n description: \"Recalculate internal totals\",\n input: z.object({}),\n access: { roles: [\"owner\"] },\n handler: \"./handlers/recalc\",\n cms: { hidden: true }, // Never shown in CMS\n},\n```\n\n## Destructive Actions\n\nActions are styled as destructive when either:\n\n- The action's `cms.destructive` is `true`\n- The action is named `void` or `delete` (legacy fallback)\n\nDestructive actions receive:\n\n- **Red text** in the row action menu\n- **Warning triangle icon** (unless overridden by `cms.icon`)\n- **Red execute button** in the dialog (using `bg-destructive` styling)\n- **Automatic confirmation step** (unless `cms.confirm` is explicitly `false`)\n\nThis provides a visual cue that the action has permanent consequences.\n\n## Standalone Actions\n\nActions with `standalone: true` are not tied to a specific record. They appear in a separate section and don't receive a `recordId` when executed. The CMS passes `null` as the record ID for standalone actions.\n\n## File Response Actions\n\nActions with `responseType: \"file\"` show a download icon instead of the lightning bolt. When executed, the response is treated as a file download rather than a JSON result.\n\n## Execution Flow\n\n1. User clicks action in row menu or action bar\n2. Dialog opens with description, inputs, and warnings\n3. If `cms.confirm` is set, user sees confirmation step first\n4. User fills in required fields\n5. User clicks \"Execute\" (or \"Confirm\" after confirmation step)\n6. CMS calls `client.executeAction(table, actionName, recordId, input)`\n7. On success: `cms.successMessage` shown as toast (if set), then `cms.onSuccess` behavior\n8. On error: error message displayed in dialog\n\n## Next Steps\n\n- **[Security](/cms/security)** — Access conditions and role-based access\n- **[Table Views](/cms/table-views)** — Where actions appear in the UI\n- **[Schema Format Reference](/cms/schema-format)** — ActionMeta type definition"
|
|
75
|
+
},
|
|
76
|
+
"cms/components": {
|
|
77
|
+
"title": "Components Reference",
|
|
78
|
+
"content": "# Components Reference\n\nThe CMS is built from composable React components organized by function. Every component reads metadata from the schema registry and adapts its behavior based on the current role.\n\n## Layout\n\n### Sidebar\n\nThe main navigation sidebar. Reads the schema registry to build a collapsible feature-grouped table list.\n\n```typescript\ninterface SidebarProps {\n // No props — reads schema registry directly\n}\n```\n\n| Behavior | Description |\n|----------|-------------|\n| Feature grouping | Tables grouped by feature with collapsible sections |\n| Feature icons | Each feature gets a contextual icon (e.g., Calculator for accounting) |\n| Active state | Current table highlighted with primary color |\n| Internal filter | Tables with `internal: true` are hidden |\n| Footer stats | Shows total feature and table counts |\n\n### Header\n\nTop bar with tagline and role switcher.\n\n```typescript\ninterface HeaderProps {\n // No props — renders tagline and RoleSwitcher\n}\n```\n\n### RoleSwitcher\n\nDropdown to switch between `owner`, `admin`, and `member` roles. Available in demo mode. In live mode, the role is read from the authenticated session.\n\n```typescript\n// Uses RoleContext internally\n// Provides: role, setRole, session\n```\n\n## Table\n\n### DataTable\n\nStandard browse table with clickable rows and sortable columns.\n\n```typescript\ninterface DataTableProps {\n data: Record[];\n columns: ColumnDef[];\n onRowClick?: (row: Record) => void;\n}\n```\n\n| Prop | Description |\n|------|-------------|\n| `data` | Array of records to display |\n| `columns` | Column definitions (built by `ColumnFactory`) |\n| `onRowClick` | Handler when a row is clicked (navigates to detail view) |\n\n### SpreadsheetTable\n\nExcel/Google Sheets-like editable table with cell selection and keyboard navigation.\n\n```typescript\ninterface SpreadsheetTableProps {\n data: Record[];\n columns: ColumnDef[];\n onCellEdit: (recordId: string, field: string, value: unknown) => Promise<void>;\n editableFields: Set<string>;\n onFKSearch: (targetTable: string, query: string) => Promise<FKOption[]>;\n}\n```\n\n| Prop | Description |\n|------|-------------|\n| `data` | Array of records to display |\n| `columns` | Column definitions |\n| `onCellEdit` | Called when a cell value is saved (auto-saves on blur/Enter) |\n| `editableFields` | Set of field names that can be edited (from guards) |\n| `onFKSearch` | Async function for FK typeahead search |\n\n### Toolbar\n\nSearch bar, view selector, view mode toggle, and refresh button.\n\n```typescript\ninterface ToolbarProps {\n table: TableMeta;\n search: string;\n onSearchChange: (search: string) => void;\n onRefresh: () => void;\n selectedView?: string;\n onViewChange: (view: string | undefined) => void;\n viewMode: \"table\" | \"dataTable\";\n onViewModeChange: (mode: \"table\" | \"dataTable\") => void;\n}\n```\n\n| Prop | Description |\n|------|-------------|\n| `table` | Table metadata (used to build view dropdown) |\n| `search` | Current search query |\n| `onSearchChange` | Handler for search input changes |\n| `onRefresh` | Refreshes the table data |\n| `selectedView` | Currently selected view name (undefined = all fields) |\n| `onViewChange` | Handler for view selection changes |\n| `viewMode` | Current view mode |\n| `onViewModeChange` | Handler for toggling between Table and Data Table |\n\n### Pagination\n\nPage navigation controls with range indicator.\n\n```typescript\ninterface PaginationProps {\n page: number;\n pageSize: number;\n total: number;\n onPageChange: (page: number) => void;\n}\n```\n\n| Prop | Description |\n|------|-------------|\n| `page` | Current page number (1-indexed) |\n| `pageSize` | Records per page |\n| `total` | Total record count |\n| `onPageChange` | Handler for page changes |\n\n### RowActions\n\nThree-dot dropdown menu on each table row with view, edit, delete, and custom actions.\n\n```typescript\ninterface RowActionsProps {\n table: TableMeta;\n record: Record;\n onDelete?: (id: string) => void;\n onAction?: (action: ActionMeta, record: Record) => void;\n}\n```\n\n| Prop | Description |\n|------|-------------|\n| `table` | Table metadata (used for CRUD access checks and action filtering) |\n| `record` | The row's record data |\n| `onDelete` | Handler for delete action |\n| `onAction` | Handler for custom action selection |\n\n### ColumnFactory\n\nUtility function (not a component) that builds column definitions from table metadata.\n\n```typescript\nfunction buildColumns(\n table: TableMeta,\n role: Role,\n options?: { viewFields?: string[] }\n): ColumnDef[];\n```\n\nGenerates columns with appropriate formatters for each field type: dates, money, booleans, enums, FK references, masked values, and plain text. Respects view field projections when provided.\n\n## Record\n\n### RecordDetail\n\nGrouped field display for a single record. Auto-groups fields by category (Identity, Contact, Financial, References, Settings, Dates, Audit).\n\n```typescript\ninterface RecordDetailProps {\n table: TableMeta;\n record: Record;\n role: Role;\n}\n```\n\n| Prop | Description |\n|------|-------------|\n| `table` | Table metadata (columns, masking, validation) |\n| `record` | The record data to display |\n| `role` | Current user role (affects masking) |\n\n### FieldDisplay\n\nRenders a single field value with appropriate formatting: masking, booleans, enums, FK links, dates, money, percentages, and numbers.\n\n```typescript\ninterface FieldDisplayProps {\n column: ColumnMeta;\n value: unknown;\n role: Role;\n masking?: Record<string, MaskingRule>;\n validation?: Record<string, ValidationRule>;\n}\n```\n\n| Prop | Description |\n|------|-------------|\n| `column` | Column metadata (type, mode, name) |\n| `value` | The raw value to display |\n| `role` | Current role (for masking checks) |\n| `masking` | Masking rules for the table |\n| `validation` | Validation rules (used for enum detection) |\n\n### ActionBar\n\nCard displaying available actions as buttons for the current record.\n\n```typescript\ninterface ActionBarProps {\n table: TableMeta;\n record: Record;\n onAction: (action: ActionMeta) => void;\n}\n```\n\n| Prop | Description |\n|------|-------------|\n| `table` | Table metadata |\n| `record` | Current record (used for access condition evaluation) |\n| `onAction` | Handler when an action button is clicked |\n\n### RelatedRecords\n\nCard showing incoming FK relationships with record counts. Lists tables that reference the current record.\n\n```typescript\ninterface RelatedRecordsProps {\n table: TableMeta;\n recordId: string;\n}\n```\n\n| Prop | Description |\n|------|-------------|\n| `table` | Table metadata (used to discover incoming FK relationships) |\n| `recordId` | Current record ID (used to count related records) |\n\n## Form\n\n### AutoForm\n\nAuto-generated create/edit form built from the table's guard configuration.\n\n```typescript\ninterface AutoFormProps {\n table: TableMeta;\n mode: \"create\" | \"edit\";\n initialData?: Record;\n onSubmit: (data: Record) => Promise<void>;\n onCancel: () => void;\n}\n```\n\n| Prop | Description |\n|------|-------------|\n| `table` | Table metadata (guards, columns, validation) |\n| `mode` | `\"create\"` uses createable fields, `\"edit\"` uses updatable fields |\n| `initialData` | Pre-filled values for edit mode |\n| `onSubmit` | Handler for form submission |\n| `onCancel` | Handler for cancel button |\n\nFeatures:\n\n- Fields determined from guards (createable for create, updatable for edit)\n- Immutable fields shown as disabled with lock icon\n- Protected fields shown as disabled with \"Updated via actions only\" note\n- Client-side validation from schema rules\n- Boolean fields grouped in a \"Settings\" section\n- Required fields marked with red asterisk\n\n### FieldInput\n\nSingle form input that adapts to the column type and validation rules.\n\n```typescript\ninterface FieldInputProps {\n column: ColumnMeta;\n validation?: ValidationRule;\n value: unknown;\n onChange: (value: unknown) => void;\n error?: string;\n disabled?: boolean;\n}\n```\n\n| Prop | Description |\n|------|-------------|\n| `column` | Column metadata (determines input type) |\n| `validation` | Validation rules (enum values, min/max, email) |\n| `value` | Current field value |\n| `onChange` | Handler for value changes |\n| `error` | Error message to display |\n| `disabled` | Whether the input is disabled |\n\nRenders as: text input, number input, email input, URL input, date picker, select dropdown (for enums), textarea (for long text), or checkbox (for booleans).\n\n## Actions\n\n### ActionDialog\n\nModal dialog for executing a custom action with auto-generated input fields.\n\n```typescript\ninterface ActionDialogProps {\n action: ActionMeta;\n record: Record;\n tableName: string;\n onClose: () => void;\n onExecute: (input: Record) => Promise<void>;\n}\n```\n\n| Prop | Description |\n|------|-------------|\n| `action` | Action metadata (name, description, inputFields, sideEffects) |\n| `record` | The record the action applies to |\n| `tableName` | Table name (for context) |\n| `onClose` | Handler to close the dialog |\n| `onExecute` | Handler to execute the action with form data |\n\nFeatures:\n\n- Auto-generates input fields from action schema\n- Destructive actions get red styling and warning icon\n- File response actions get download icon\n- Side effects warning banner for `sideEffects: \"sync\"`\n- Loading state during execution\n- Error display on failure\n\n## Next Steps\n\n- **[Schema Format Reference](/cms/schema-format)** — TypeScript types consumed by these components\n- **[Table Views](/cms/table-views)** — How DataTable and SpreadsheetTable are used\n- **[Inline Editing](/cms/inline-editing)** — SpreadsheetTable editing details"
|
|
79
|
+
},
|
|
80
|
+
"cms/connecting": {
|
|
81
|
+
"title": "Connecting",
|
|
82
|
+
"content": "# Connecting\n\nThe CMS supports two modes: **demo mode** for development and testing, and **live mode** for connecting to a real Quickback API.\n\n## Demo Mode\n\nWhen no `VITE_API_URL` is set, the CMS runs in demo mode:\n\n- Uses a **mock API client** backed by `localStorage`\n- Loads seed data from JSON files in `data/mock/`\n- Provides a **role switcher** in the header to test owner/admin/member access\n- Simulates authentication sessions without a real backend\n\nDemo mode is useful for prototyping your schema, testing guard behavior across roles, and verifying masking rules before deploying.\n\n```bash title=\".env.development\"\n# No VITE_API_URL — CMS runs in demo mode\n```\n\n### Mock Data\n\nPlace JSON files in `data/mock/` matching your table names:\n\n```\ndata/mock/\n contact.json # Array of contact records\n project.json # Array of project records\n invoice.json # Array of invoice records\n _meta.json # Mock session and org metadata\n```\n\nEach file contains an array of records. The mock client loads them on startup and persists changes to `localStorage`.\n\n### Role Switcher\n\nIn demo mode, the header displays a role switcher dropdown allowing you to switch between `owner`, `admin`, and `member` roles in real time. This lets you verify:\n\n- Which CRUD buttons appear per role\n- Which form fields are editable\n- Which masking rules apply\n- Which actions are available\n- Which views are accessible\n\n## Live Mode\n\nSet `VITE_API_URL` to connect to a real Quickback API:\n\n```bash title=\".env.production\"\nVITE_API_URL=https://api.example.com\n```\n\nIn live mode, the CMS:\n\n- Reads the **Better Auth session** from cookies\n- Fetches the user's **organization membership** and role\n- Makes real API calls to the Quickback backend for all CRUD operations\n- Applies server-side security (firewall, guards, masking) in addition to client-side UI filtering\n\n## API Client Interface\n\nThe CMS communicates with the backend through a standard interface:\n\n```typescript\ninterface IApiClient {\n list(table: string, params?: ListParams): Promise\n\n## How the Client is Created\n\nThe CMS auto-creates the API client based on configuration:\n\n1. **No `VITE_API_URL`** — Instantiates the mock client, loads seed data from `data/mock/`, and uses `localStorage` for persistence.\n2. **`VITE_API_URL` is set** — Instantiates the live client, reads the auth session cookie, and makes fetch requests to the Quickback API using the standard REST endpoints (`/api/v1/{table}`).\n\nThe client is provided via React context (`ApiClientContext`) and available throughout the component tree.\n\n## Next Steps\n\n- **[Table Views](/cms/table-views)** — Browse and Data Table view modes\n- **[Security](/cms/security)** — How roles, guards, and masking work in the CMS"
|
|
83
|
+
},
|
|
84
|
+
"cms": {
|
|
85
|
+
"title": "Quickback CMS",
|
|
86
|
+
"content": "# Quickback CMS\n\nA schema-driven admin interface that reads `schema-registry.json` generated by the Quickback compiler. Every table, column, action, view, and security rule is rendered automatically. Zero UI code per table.\n\n## Overview\n\nThe CMS generates its entire UI from your Quickback definitions. Define a table with columns, guards, masking, views, and actions in your feature files. Run the compiler. The CMS reads the resulting schema registry and renders a complete admin interface — data tables, inline editing, action dialogs, role-based access, and field masking — all without writing a single line of UI code.\n\n## Key Features\n\n- **Schema-driven** — Zero UI code per table. Add a table, recompile, and it appears in the CMS.\n- **Dual view modes** — Table browse mode for navigation and Data Table mode for spreadsheet-style editing.\n- **Role-based access** — Owner, admin, and member roles with live switching. CRUD buttons hidden when unauthorized.\n- **Inline spreadsheet editing** — Excel/Google Sheets-like editing with keyboard navigation (arrows, Tab, Enter, Escape).\n- **FK typeahead** — Server-side search for foreign key fields with debounced queries and keyboard navigation.\n- **Field masking** — Email, phone, SSN, and redaction patterns applied per role. Masked fields show a lock icon.\n- **Custom actions** — Action dialogs with auto-generated input forms, access filtering, CMS metadata (icons, categories, confirmations), and side effects warnings.\n- **Views** — Named column-level projections per role. \"All Fields\" plus custom views in the toolbar.\n- **Auto-form generation** — Create and edit forms built from guards (createable/updatable fields).\n- **Display column auto-detection** — FK labels resolved automatically from `name`, `title`, `label`, `code`, and other common patterns.\n\n## Architecture\n\nThe CMS sits at the end of the Quickback compilation pipeline:\n\n```\nQuickback Definitions (feature files)\n |\n v\n Compiler\n |\n v\n schema-registry.json\n |\n v\n CMS reads it\n |\n v\n Renders admin UI\n```\n\nYour feature definitions are the single source of truth. The compiler extracts all metadata — columns, types, guards, masking rules, views, actions, validation, and firewall config — into a static JSON file. The CMS consumes that file and renders the appropriate UI for each table.\n\n## Quick Start\n\n### 1. Enable Schema Registry Generation\n\nAdd `schemaRegistry` to your `quickback.config.ts`:\n\n```typescript title=\"quickback/quickback.config.ts\"\nexport default defineConfig({\n schemaRegistry: { generate: true },\n // ... rest of your config\n});\n```\n\n### 2. Compile\n\n```bash\nquickback compile\n```\n\nThis generates `schema-registry.json` alongside your compiled API output.\n\n### 3. Point the CMS at Your Schema\n\nThe CMS reads the generated `schema-registry.json` to discover all tables, columns, security rules, and actions. In development, it can also run in demo mode with mock data and a role switcher for testing different access levels.\n\n## Next Steps\n\n- **[Schema Registry](/cms/schema-registry)** — Understand the JSON format the compiler generates\n- **[Connecting](/cms/connecting)** — Demo mode vs. live mode setup\n- **[Table Views](/cms/table-views)** — Browse and Data Table view modes\n- **[Inline Editing](/cms/inline-editing)** — Spreadsheet-style editing and FK typeahead\n- **[Security](/cms/security)** — How the CMS enforces all four security layers\n- **[Actions](/cms/actions)** — Custom actions with input forms, access filtering, and CMS metadata\n- **[Schema Format Reference](/cms/schema-format)** — Full TypeScript types for schema-registry.json\n- **[Components Reference](/cms/components)** — All CMS components and their props"
|
|
87
|
+
},
|
|
88
|
+
"cms/inline-editing": {
|
|
89
|
+
"title": "Inline Editing",
|
|
90
|
+
"content": "# Inline Editing\n\nThe CMS provides spreadsheet-style inline editing in Data Table mode, plus auto-generated create and edit forms in the record detail view. All editable fields are determined from your table's guard configuration.\n\n## Spreadsheet Editing\n\nIn Data Table mode, every editable cell becomes an inline editor. Select a cell, start typing, and the value is saved automatically when you move to another cell or press Enter.\n\n### Editable Fields\n\nThe CMS determines which fields are editable from the table's guards:\n\n- **`guards.updatable`** fields are editable in existing records\n- **`guards.createable`** fields are editable in new record forms\n- **`guards.immutable`** fields are locked after creation (shown as disabled in edit mode)\n- **`guards.protected`** fields can only be changed via actions (marked \"Updated via actions only\")\n\nIf a table has no explicit guard config, the CMS falls back to making all non-system columns editable (excluding `id`, audit fields, `organizationId`, and `deletedAt`).\n\n## Cell Types\n\nEach column type gets a specialized editor:\n\n### Text\n\nStandard text input. Type freely and press Enter or Tab to save.\n\n### Number\n\nNumeric input with step controls. Supports decimal values (step `0.01` for currency fields). The CMS detects money fields from column names containing `amount`, `total`, `price`, `cost`, `balance`, `rate`, etc.\n\n### Boolean\n\nA Yes/No dropdown. Click to toggle between the two values.\n\n### FK Typeahead\n\nForeign key columns (ending in `Id`) get a typeahead dropdown with server-side search:\n\n- **Debounced search** — Queries are debounced at 200ms to avoid flooding the API\n- **Server-side filtering** — Sends `?search=query&pageSize=20` to the FK target table's list endpoint\n- **Keyboard navigation** — Arrow keys to navigate options, Enter to select, Escape to close\n- **Auto-loads initial options** — Opens with the first 20 records sorted by display column\n- **Display labels** — Shows the target table's display column value (e.g., \"John Smith\" instead of `usr_abc123`)\n\nThe FK target table is derived by stripping the `Id` suffix from the column name. For example, `contactId` searches the `contact` table, and `accountCodeId` searches the `accountCode` table.\n\n### Enum / Select\n\nColumns with `validation.enum` render as a select dropdown with all allowed values.\n\n## Display Labels\n\nFK cells in the table view show human-readable names instead of raw IDs. The API enriches list responses with `_label` fields:\n\n```\ncontactId: \"cnt_abc123\" → displays \"Acme Corporation\"\nprojectId: \"prj_xyz789\" → displays \"Beach House Renovation\"\n```\n\nThe label comes from the referenced table's display column (auto-detected from `name`, `title`, `code`, etc.). This works in both Table mode and Data Table mode.\n\n## Keyboard Shortcuts\n\n| Shortcut | Action |\n|----------|--------|\n| Arrow keys | Navigate between cells |\n| Tab | Move to next editable cell |\n| Shift + Tab | Move to previous editable cell |\n| Enter | Start editing / confirm edit and move down |\n| Escape | Cancel current edit / deselect cell |\n| Type any character | Start editing the selected cell |\n\n### Navigation Flow\n\n1. **Select** a cell by clicking or using arrow keys\n2. **Edit** by pressing Enter, Tab, or just typing\n3. **Save** by pressing Enter (moves down), Tab (moves right to next editable), or clicking elsewhere\n4. **Cancel** by pressing Escape (reverts to previous value)\n\nWhen you press Tab, the cursor skips non-editable cells and jumps to the next editable cell in the row. At the end of a row, it wraps to the first editable cell of the next row.\n\n## Record Detail View\n\nClicking a row in Table mode navigates to the record detail view. Fields are automatically grouped by type:\n\n| Group | Fields Included |\n|-------|----------------|\n| Identity | `name`, `code`, `status`, `title`, `companyName`, fields ending in `Type` or `Status` |\n| Contact Info | `email`, `phone`, `mobile`, `website`, `address1`, `address2`, `city`, `state`, `zip`, `country` |\n| Financial | Money fields, fields with `Percent`, `Rate`, `Markup`, `Discount`, `Currency`, `exchange` |\n| References | All FK columns (ending in `Id`, excluding `id` and `organizationId`) |\n| Settings | Boolean fields |\n| Dates | Date/time fields |\n| Audit | `createdAt`, `createdBy`, `modifiedAt`, `modifiedBy` |\n| Other | Remaining ungrouped fields |\n\nEach group is rendered as a card with the group label as a header. Fields display formatted values — dates are localized, money shows currency formatting, booleans render as Yes/No badges, enums use color-coded pills, and FK references are clickable links to the target record.\n\n## Auto-Generated Forms\n\nThe CMS generates create and edit forms from the table's guard configuration:\n\n### Create Form\n\nShows all fields listed in `guards.createable`. Required fields (notNull without a default) are marked with a red asterisk. Validation rules from the schema (min/max length, enum constraints, email format) are enforced client-side.\n\n### Edit Form\n\nShows all fields listed in `guards.updatable`. Additionally:\n\n- **Immutable fields** appear as disabled inputs with a lock icon\n- **Protected fields** appear as disabled inputs with the note \"Updated via actions only\"\n- **Validation** is applied on submit, with error messages shown per field\n\n### Field Input Types\n\nThe auto-form selects the appropriate input control based on column type and validation:\n\n| Detection | Input Type |\n|-----------|-----------|\n| `mode: \"boolean\"` | Checkbox |\n| `validation.enum` present | Select dropdown |\n| Column name matches date pattern | Date/time picker |\n| Column name matches money pattern | Number input (step 0.01) |\n| Column name matches URL pattern | URL input |\n| `validation.email: true` | Email input |\n| `type: \"integer\"` or `type: \"real\"` | Number input |\n| Long text (description, notes) | Textarea |\n| Default | Text input |\n\n## Next Steps\n\n- **[Table Views](/cms/table-views)** — Browse mode and view projections\n- **[Security](/cms/security)** — How guards control editability\n- **[Actions](/cms/actions)** — Trigger actions from the detail view"
|
|
91
|
+
},
|
|
92
|
+
"cms/schema-format": {
|
|
93
|
+
"title": "Schema Format Reference",
|
|
94
|
+
"content": "# Schema Format Reference\n\nThe schema registry is a JSON file with a well-defined structure. Below are the complete TypeScript type definitions used by both the compiler (to generate) and the CMS (to consume).\n\n## SchemaRegistry\n\nThe top-level type:\n\n```typescript\ninterface SchemaRegistry {\n generatedAt: string;\n generatedBy: string;\n version: string;\n features: Record<string, string[]>;\n tables: Record<string, TableMeta>;\n tablesByFeature: Record<string, string[]>;\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `generatedAt` | ISO 8601 timestamp of when the registry was generated |\n| `generatedBy` | Always `\"quickback-compiler\"` |\n| `version` | Compiler version string |\n| `features` | Map of feature name to array of source file names |\n| `tables` | Map of camelCase table name to full table metadata |\n| `tablesByFeature` | Map of feature name to array of table names in that feature |\n\n## TableMeta\n\nFull metadata for a single table:\n\n```typescript\ninterface TableMeta {\n name: string;\n dbName: string;\n feature: string;\n columns: ColumnMeta[];\n firewall: Record<string, unknown>;\n crud: Record<string, CrudConfig>;\n guards: GuardsConfig;\n masking: Record<string, MaskingRule>;\n views: Record<string, ViewConfig>;\n validation: Record<string, ValidationRule>;\n actions: ActionMeta[];\n displayColumn?: string;\n internal?: boolean;\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `name` | camelCase table name (e.g., `\"accountCode\"`) |\n| `dbName` | Snake_case SQL table name (e.g., `\"account_code\"`) |\n| `feature` | Parent feature name (e.g., `\"accounting\"`) |\n| `columns` | Ordered array of column metadata |\n| `firewall` | Tenant isolation config (organization, owner, softDelete, exception) |\n| `crud` | Per-operation (create, read, update, delete) access config |\n| `guards` | Field-level create/update/immutable/protected rules |\n| `masking` | Per-field masking rules keyed by column name |\n| `views` | Named column projections keyed by view name |\n| `validation` | Per-field validation rules keyed by column name |\n| `actions` | Array of action definitions for this table |\n| `displayColumn` | Column used as human-readable label (auto-detected or explicit) |\n| `internal` | When `true`, table is hidden from CMS sidebar |\n\n## ColumnMeta\n\nMetadata for a single column:\n\n```typescript\ninterface ColumnMeta {\n name: string;\n dbName: string;\n type: \"text\" | \"integer\" | \"real\" | \"blob\";\n mode?: \"boolean\";\n primaryKey: boolean;\n notNull: boolean;\n defaultValue?: string | number | boolean;\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `name` | camelCase property name |\n| `dbName` | Snake_case SQL column name |\n| `type` | SQLite storage type |\n| `mode` | When `\"boolean\"`, an integer column represents true/false |\n| `primaryKey` | Whether this column is the primary key |\n| `notNull` | Whether the column has a NOT NULL constraint |\n| `defaultValue` | Static default value (strings, numbers, or booleans) |\n\n## CRUDConfig\n\nPer-operation access control:\n\n```typescript\ninterface CrudConfig {\n access?: AccessRule;\n mode?: string;\n}\n\ninterface AccessRule {\n roles?: string[];\n or?: Array<{\n roles?: string[];\n record?: Record<string, unknown>;\n }>;\n record?: Record<string, unknown>;\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `access.roles` | Array of roles allowed for this operation |\n| `access.or` | Alternative access conditions (any must match) |\n| `access.record` | Record-level conditions for access |\n| `mode` | Operation mode (e.g., `\"batch\"` for bulk create) |\n\n## GuardsConfig\n\nField-level control for create and update operations:\n\n```typescript\ninterface GuardsConfig {\n createable: string[];\n updatable: string[];\n immutable: string[];\n protected: Record<string, string[]>;\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `createable` | Fields that can be set during record creation |\n| `updatable` | Fields that can be modified on existing records |\n| `immutable` | Fields that can be set on create but never changed |\n| `protected` | Fields only modifiable via named actions (field name to action names) |\n\n## ActionMeta\n\nMetadata for a custom action:\n\n```typescript\ninterface ActionMeta {\n name: string;\n description: string;\n inputFields: ActionInputField[];\n access?: {\n roles: string[];\n record?: Record<string, unknown>;\n };\n standalone?: boolean;\n path?: string;\n method?: string;\n responseType?: string;\n sideEffects?: string;\n cms?: CmsConfig;\n}\n\ninterface CmsConfig {\n label?: string;\n icon?: string;\n confirm?: string | boolean;\n destructive?: boolean;\n category?: string;\n hidden?: boolean;\n successMessage?: string;\n onSuccess?: 'refresh' | 'redirect:list' | 'close';\n order?: number;\n}\n\ninterface ActionInputField {\n name: string;\n type: string;\n required: boolean;\n default?: unknown;\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `name` | Action identifier (e.g., `\"approve\"`, `\"applyPayment\"`) |\n| `description` | Human-readable description shown in dialog |\n| `inputFields` | Array of input field definitions |\n| `access.roles` | Roles allowed to execute this action |\n| `access.record` | Record conditions (e.g., `{ status: { equals: \"pending\" } }`) |\n| `standalone` | When `true`, action is not tied to a specific record |\n| `path` | Custom API path (overrides default) |\n| `method` | HTTP method (defaults to POST) |\n| `responseType` | `\"file\"` for download responses |\n| `sideEffects` | `\"sync\"` for actions with synchronous side effects |\n| `cms` | Optional CMS rendering metadata (label, icon, confirm, destructive, category, hidden, successMessage, onSuccess, order) |\n\n### ActionInputField\n\n| Field | Description |\n|-------|-------------|\n| `name` | Field identifier |\n| `type` | Zod type string: `\"string\"`, `\"number\"`, `\"boolean\"`, `\"array<string>\"` |\n| `required` | Whether the field must be provided |\n| `default` | Default value pre-filled in the form |\n\n## ViewConfig\n\nNamed column projection with access control:\n\n```typescript\ninterface ViewConfig {\n fields: string[];\n access: AccessRule;\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `fields` | Array of column names to include in this view |\n| `access` | Role-based access rules (same shape as CrudConfig access) |\n\n## MaskingRule\n\nPer-field data masking:\n\n```typescript\ninterface MaskingRule {\n type: \"email\" | \"phone\" | \"ssn\" | \"redact\";\n show: {\n roles: string[];\n or?: string;\n };\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `type` | Masking pattern to apply |\n| `show.roles` | Roles that see the unmasked value |\n| `show.or` | Alternative condition for showing unmasked value |\n\n### Masking Patterns\n\n| Type | Input | Output |\n|------|-------|--------|\n| `email` | `john@acme.com` | `j***@acme.com` |\n| `phone` | `(555) 123-4567` | `***-***-4567` |\n| `ssn` | `123-45-6789` | `***-**-6789` |\n| `redact` | Any string | `------` |\n\n## ValidationRule\n\nPer-field validation constraints:\n\n```typescript\ninterface ValidationRule {\n minLength?: number;\n maxLength?: number;\n min?: number;\n max?: number;\n enum?: string[];\n email?: boolean;\n}\n```\n\n| Field | Description |\n|-------|-------------|\n| `minLength` | Minimum string length |\n| `maxLength` | Maximum string length |\n| `min` | Minimum numeric value |\n| `max` | Maximum numeric value |\n| `enum` | Array of allowed string values |\n| `email` | When `true`, validates email format |\n\n## Next Steps\n\n- **[Schema Registry](/cms/schema-registry)** — How the registry is generated and used\n- **[Components Reference](/cms/components)** — All CMS React components"
|
|
95
|
+
},
|
|
96
|
+
"cms/schema-registry": {
|
|
97
|
+
"title": "Schema Registry",
|
|
98
|
+
"content": "# Schema Registry\n\nThe schema registry is a static JSON file generated by the Quickback compiler. It contains full metadata about every table in your project — columns, types, guards, masking rules, views, actions, validation, and firewall config. The CMS reads this file to render its entire UI.\n\n## Enabling Generation\n\nAdd `schemaRegistry` to your config:\n\n```typescript title=\"quickback/quickback.config.ts\"\nexport default defineConfig({\n schemaRegistry: { generate: true },\n // ... rest of your config\n});\n```\n\nRun `quickback compile` and the compiler outputs `schema-registry.json` alongside your compiled API files.\n\n## Output Shape\n\nThe top-level structure of `schema-registry.json`:\n\n```json\n{\n \"generatedAt\": \"2026-02-14T06:26:53.554Z\",\n \"generatedBy\": \"quickback-compiler\",\n \"version\": \"1.0.0\",\n \"features\": { ... },\n \"tables\": { ... },\n \"tablesByFeature\": { ... }\n}\n```\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `generatedAt` | `string` | ISO timestamp of generation |\n| `generatedBy` | `string` | Always `\"quickback-compiler\"` |\n| `version` | `string` | Compiler version used |\n| `features` | `Record<string, string[]>` | Feature name to file list mapping |\n| `tables` | `Record<string, TableMeta>` | Table name to full metadata |\n| `tablesByFeature` | `Record<string, string[]>` | Feature name to table name list |\n\n## TableMeta\n\nEach table entry contains everything the CMS needs to render its UI:\n\n```typescript\ninterface TableMeta {\n name: string; // camelCase table name (e.g., \"accountCode\")\n dbName: string; // SQL table name (e.g., \"account_code\")\n feature: string; // Parent feature name\n columns: ColumnMeta[]; // All columns including audit fields\n firewall: Record<string, unknown>; // Tenant isolation config\n crud: Record<string, CrudConfig>; // Per-operation access rules\n guards: {\n createable: string[]; // Fields allowed on create\n updatable: string[]; // Fields allowed on update\n immutable: string[]; // Fields locked after creation\n protected: Record<string, string[]>; // Fields only updatable via actions\n };\n masking: Record<string, MaskingRule>; // Per-field masking rules\n views: Record<string, ViewConfig>; // Named column projections\n validation: Record<string, ValidationRule>; // Per-field validation\n actions: ActionMeta[]; // Available actions for this table\n displayColumn?: string; // Human-readable label column\n internal?: boolean; // Hidden from CMS sidebar when true\n}\n```\n\n## ColumnMeta\n\nEach column in the `columns` array:\n\n```typescript\ninterface ColumnMeta {\n name: string; // Property name (camelCase)\n dbName: string; // SQL column name (snake_case)\n type: \"text\" | \"integer\" | \"real\" | \"blob\"; // SQLite type\n mode?: \"boolean\"; // When an integer represents a boolean\n primaryKey: boolean;\n notNull: boolean;\n defaultValue?: string | number | boolean;\n}\n```\n\nThe compiler automatically includes audit fields (`id`, `organizationId`, `createdAt`, `createdBy`, `modifiedAt`, `modifiedBy`, `deletedAt`) at the beginning of every table's column list.\n\n## Display Column\n\nThe `displayColumn` field tells the CMS which column to use as a human-readable label for a record. This is used in:\n\n- FK typeahead dropdowns (showing names instead of IDs)\n- Record titles in detail views\n- Breadcrumb labels\n\n### Auto-Detection\n\nIf you don't explicitly set `displayColumn` in your resource config, the compiler auto-detects it by scanning column names in priority order:\n\n1. `name`\n2. `title`\n3. `label`\n4. `headline`\n5. `subject`\n6. `code`\n7. `displayName`\n8. `fullName`\n9. `description`\n\nThe first match wins. If no candidate matches, the table has no display column and the CMS falls back to showing IDs.\n\n### Explicit Config\n\nSet it explicitly in your table definition:\n\n```typescript\nexport default defineTable(contacts, {\n displayColumn: \"companyName\",\n // ...\n});\n```\n\n## FK Label Resolution\n\nWhen a table has foreign key columns (ending in `Id`), the API enriches list responses with `_label` fields. For example, a `roomTypeId` column gets a corresponding `roomType_label` field containing the display column value from the referenced table.\n\nThe CMS uses these `_label` fields to show human-readable names in table cells and FK typeahead dropdowns instead of raw UUIDs.\n\n```\nroomTypeId: \"rt_abc123\" → displayed as \"Master Bedroom\"\naccountCodeId: \"ac_xyz789\" → displayed as \"4100 - Revenue\"\n```\n\nThe FK target table is derived by stripping the `Id` suffix: `roomTypeId` references the `roomType` table. The CMS confirms the target exists in the registry before rendering a link.\n\n## Next Steps\n\n- **[Connecting](/cms/connecting)** — Demo mode vs. live mode setup\n- **[Schema Format Reference](/cms/schema-format)** — Full TypeScript types"
|
|
99
|
+
},
|
|
100
|
+
"cms/security": {
|
|
101
|
+
"title": "Security",
|
|
102
|
+
"content": "# Security\n\nThe CMS respects all four Quickback security layers. Every UI element — buttons, form fields, table columns, action menus — adapts based on the current user's role and the security rules defined in your feature files.\n\n## The Four Layers\n\nQuickback enforces security through four complementary layers:\n\n| Layer | Purpose | CMS Behavior |\n|-------|---------|-------------|\n| **Firewall** | Tenant isolation (org-scoped data) | Handled server-side. CMS cannot bypass it. |\n| **CRUD Access** | Per-operation role requirements | Buttons hidden when role lacks permission |\n| **Guards** | Field-level create/update control | Form fields enabled/disabled per guard rules |\n| **Masking** | Sensitive field redaction | Masked values with lock icons per role |\n\n## Role-Based Access\n\nThe CMS header includes a **role switcher** (in demo mode) or reads the user's actual role from their organization membership (in live mode). The three roles are:\n\n- **owner** — Full access to all operations and data\n- **admin** — Elevated access, typically all CRUD and most actions\n- **member** — Standard access with restrictions on sensitive data and destructive actions\n\n### CRUD Button Visibility\n\nCRUD operation buttons are shown or hidden based on the current role's access:\n\n```typescript\ncrud: {\n create: { access: { roles: ['admin', 'owner'] } },\n read: { access: { roles: ['member', 'admin', 'owner'] } },\n update: { access: { roles: ['admin', 'owner'] } },\n delete: { access: { roles: ['owner'] } },\n}\n```\n\nWith the config above:\n\n- **Members** see the table (read) but no Create, Edit, or Delete buttons\n- **Admins** see Create and Edit buttons but no Delete\n- **Owners** see all buttons\n\nThe row action menu also adapts — Edit and Delete entries only appear when the role has the corresponding permission.\n\n## Guards\n\nGuards control which fields appear in create and edit forms, and which cells are editable in Data Table mode.\n\n### Createable Fields\n\nFields listed in `guards.createable` appear in the create form. Fields not in this list are hidden from the form entirely:\n\n```typescript\nguards: {\n createable: ['name', 'email', 'phone', 'status'],\n}\n```\n\n### Updatable Fields\n\nFields listed in `guards.updatable` are editable in the edit form and in Data Table inline editing:\n\n```typescript\nguards: {\n updatable: ['name', 'email', 'phone'],\n}\n```\n\n### Immutable Fields\n\nFields in `guards.immutable` can be set during creation but cannot be changed afterward. In edit mode, they appear as disabled inputs with a lock icon:\n\n```typescript\nguards: {\n immutable: ['accountCode', 'type'],\n}\n```\n\n### Protected Fields\n\nFields in `guards.protected` can only be updated via specific actions. They appear as disabled inputs with the message \"Updated via actions only\":\n\n```typescript\nguards: {\n protected: {\n status: ['approve', 'void'], // Updated by approve or void actions\n balance: ['applyPayment'], // Updated by applyPayment action\n },\n}\n```\n\n## Masking\n\nMasking rules redact sensitive data based on the user's role. The CMS applies masking client-side for display and the API enforces it server-side in responses.\n\n### Masking Types\n\n| Type | Example Input | Masked Output |\n|------|--------------|---------------|\n| `email` | `john@acme.com` | `j***@acme.com` |\n| `phone` | `(555) 123-4567` | `***-***-4567` |\n| `ssn` | `123-45-6789` | `***-**-6789` |\n| `redact` | `Confidential notes` | `------` |\n\n### Configuration\n\n```typescript\nmasking: {\n email: { type: 'email', show: { roles: ['admin', 'owner'] } },\n phone: { type: 'phone', show: { roles: ['admin', 'owner'] } },\n ssn: { type: 'ssn', show: { roles: ['owner'] } },\n}\n```\n\nWith the above config:\n\n- **Members** see masked values for email, phone, and SSN\n- **Admins** see unmasked email and phone, but masked SSN\n- **Owners** see all values unmasked\n\n### Visual Indicator\n\nMasked fields display a lock icon next to the redacted value, making it clear that the field contains hidden data:\n\n```\nEmail: [lock] j***@acme.com\nSSN: [lock] ***-**-6789\n```\n\n## Views\n\nViews provide column-level projections per role. Only authorized views appear in the toolbar dropdown.\n\n```typescript\nviews: {\n summary: {\n fields: ['id', 'name', 'status'],\n access: { roles: ['member', 'admin', 'owner'] },\n },\n financial: {\n fields: ['id', 'name', 'balance', 'creditLimit', 'paymentTerms'],\n access: { roles: ['admin', 'owner'] },\n },\n}\n```\n\nMembers only see the \"summary\" view option. Admins and owners see both \"summary\" and \"financial\". The \"All Fields\" option is always available.\n\n## Firewall\n\nThe firewall layer handles tenant isolation — ensuring users can only access data within their organization. This is enforced entirely server-side:\n\n- `organization_id` columns are filtered automatically by the API\n- `owner` mode restricts records to the creating user\n- The CMS never sees data outside the user's organization scope\n\nThe CMS does not display firewall config in its UI because there is nothing for the user to control. Tenant isolation is transparent and automatic.\n\n## Actions\n\nAction visibility is controlled by access conditions on each action:\n\n```typescript\nactions: {\n approve: {\n access: {\n roles: ['admin', 'owner'],\n record: { status: { equals: 'pending' } },\n },\n cms: { destructive: true, confirm: true },\n },\n}\n```\n\nThe CMS evaluates both the role requirement and the record condition. In this example, the \"Approve\" action only appears for admins and owners, and only on records where `status === \"pending\"`.\n\nDestructive actions (`cms.destructive: true`, or named `void`/`delete`) receive special warning styling with red text and an alert icon. Actions with `cms.hidden: true` are hidden from the CMS entirely (API-only).\n\n## Next Steps\n\n- **[Actions](/cms/actions)** — Action dialogs and input forms\n- **[Table Views](/cms/table-views)** — View projections and toolbar\n- **[Inline Editing](/cms/inline-editing)** — How guards affect editability"
|
|
103
|
+
},
|
|
104
|
+
"cms/table-views": {
|
|
105
|
+
"title": "Table Views",
|
|
106
|
+
"content": "# Table Views\n\nThe CMS provides two view modes for every table: **Table** mode for browsing and navigating records, and **Data Table** mode for spreadsheet-style inline editing. Switch between them with the toolbar toggle.\n\n## Table Mode\n\nTable mode is the default browse experience:\n\n- **Click rows** to navigate to the record detail view\n- **Row action menus** (three-dot icon) with view, edit, delete, and custom actions\n- **Column headers** are sortable — click to toggle ascending/descending\n- **Search bar** for full-text search across all fields\n- **Responsive columns** that adapt to available space\n\nEach row shows the most relevant columns for the table. Foreign key columns display human-readable labels (resolved via `_label` fields) instead of raw UUIDs. Boolean columns render as colored Yes/No badges. Enum columns use color-coded pills.\n\n### Row Actions\n\nThe three-dot menu on each row provides:\n\n| Action | Visibility | Description |\n|--------|-----------|-------------|\n| View details | Always | Navigate to the record detail page |\n| Edit | When role has `update` access | Navigate to the edit form |\n| Custom actions | Filtered by role + record state | Run actions like approve, void, post |\n| Delete | When role has `delete` access | Delete with confirmation |\n\nActions are filtered by the current role's CRUD permissions and the action's access conditions. For example, an \"approve\" action that requires `status === \"pending\"` only appears on pending records.\n\n## Data Table Mode\n\nData Table mode provides an Excel/Google Sheets-like editing experience:\n\n- **Cell selection** with a blue focus ring\n- **Keyboard navigation** using arrow keys, Tab, Enter, and Escape\n- **Type-to-edit** — start typing to enter edit mode on the selected cell\n- **Row numbers** displayed in the leftmost column\n- **Hint bar** at the bottom showing keyboard shortcuts\n\n### Editable Fields\n\nWhich cells are editable is determined by the table's guards:\n\n- Fields in `guards.updatable` are editable\n- Fields in `guards.immutable` are read-only (shown with a disabled style)\n- Fields in `guards.protected` are read-only (marked \"Updated via actions only\")\n- Audit fields (`createdAt`, `createdBy`, `modifiedAt`, `modifiedBy`) are always read-only\n\nNon-editable cells can still be selected and copied, but they don't enter edit mode.\n\n### Cell Types\n\n| Column Type | Edit Control | Behavior |\n|-------------|-------------|----------|\n| Text | Text input | Free-text entry |\n| Number | Number input | Numeric entry with step controls |\n| Boolean | Yes/No dropdown | Toggle between Yes and No |\n| FK reference | Typeahead dropdown | Server-side search with debounced queries |\n| Enum | Select dropdown | Choose from allowed values |\n\n### Saving\n\nChanges are auto-saved when you:\n\n- Press **Enter** to confirm and move down\n- Press **Tab** to confirm and move to the next editable cell\n- **Click away** from the editing cell (blur)\n\nPress **Escape** to cancel an edit and revert to the previous value.\n\n## Views\n\nViews are named column projections defined in your table's resource config. They control which columns appear in the table based on the current role.\n\n### Toolbar\n\nThe toolbar shows a view dropdown when the table has views defined. Options include:\n\n- **All Fields** — Shows all columns (default)\n- Custom views — Only show the columns specified in the view config\n\nViews are filtered by role access. If a view's access rules require `admin` and the current user is a `member`, that view won't appear in the dropdown.\n\n### Example\n\nGiven this definition:\n\n```typescript\nviews: {\n summary: {\n fields: ['id', 'name', 'status'],\n access: { roles: ['member', 'admin'] },\n },\n full: {\n fields: ['id', 'name', 'status', 'ssn', 'internalNotes'],\n access: { roles: ['admin'] },\n },\n}\n```\n\n- Members see: \"All Fields\" and \"summary\"\n- Admins see: \"All Fields\", \"summary\", and \"full\"\n- Owners see: \"All Fields\", \"summary\", and \"full\"\n\nWhen a view is selected, the CMS calls the `listView` API endpoint instead of the standard `list`, fetching only the projected columns.\n\n## Pagination\n\nThe bottom of every table view shows pagination controls:\n\n- **Page range indicator** — \"Showing 1-25 of 142\"\n- **Page buttons** — Navigate directly to a page, with ellipsis for large page counts\n- **Previous/Next** arrows\n\nThe default page size is 25 records. The CMS resets to page 1 when you change the search query or switch views.\n\n## Search\n\nThe search bar performs full-text search across all columns. Type a query and the CMS debounces the request, then fetches matching records from the API.\n\nSearch works in both Table and Data Table modes, and works with views (searching within the projected columns).\n\n## Next Steps\n\n- **[Inline Editing](/cms/inline-editing)** — Deep dive into spreadsheet editing, FK typeahead, and keyboard shortcuts\n- **[Security](/cms/security)** — How roles and guards affect the table UI\n- **[Actions](/cms/actions)** — Custom actions in the row menu and detail view"
|
|
107
|
+
},
|
|
72
108
|
"compiler/cloud-compiler/authentication": {
|
|
73
109
|
"title": "Authentication",
|
|
74
110
|
"content": "The CLI supports two authentication methods: interactive login for development and API keys for CI/CD.\n\n## Interactive Login (Recommended)\n\nThe CLI uses [OAuth 2.0 Device Authorization](https://datatracker.ietf.org/doc/html/rfc8628) to authenticate:\n\n```bash\nquickback login\n```\n\nA code is displayed in your terminal. Approve it in your browser and you're authenticated. See the [CLI Reference](/compiler/cloud-compiler/cli#login) for the full flow.\n\nCredentials are stored at `~/.quickback/credentials.json` and include your session token, user info, and active organization.\n\n## API Key (CI/CD)\n\nFor non-interactive environments (CI/CD, scripts), use an API key:\n\n```bash\nQUICKBACK_API_KEY=your_api_key quickback compile\n```\n\nCreate API keys from your [Quickback account](https://account.quickback.dev/profile). Each key is scoped to your organization.\n\nThe API key takes precedence over stored credentials from `quickback login`.\n\n## How Tokens Are Validated\n\nThe compiler-cloud worker validates authentication by forwarding your token to the Quickback API's `/internal/validate` endpoint via a [Cloudflare service binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/). This resolves both session tokens (from `quickback login`) and API keys (from `QUICKBACK_API_KEY`).\n\n## Credential Storage\n\nCredentials are stored at `~/.quickback/credentials.json`:\n\n```json\n{\n \"token\": \"...\",\n \"user\": {\n \"id\": \"...\",\n \"email\": \"paul@example.com\",\n \"name\": \"Paul Stenhouse\",\n \"tier\": \"free\"\n },\n \"expiresAt\": \"2026-02-16T01:42:21.519Z\",\n \"organization\": {\n \"id\": \"...\",\n \"name\": \"Acme\",\n \"slug\": \"acme\"\n }\n}\n```\n\nSessions expire after 7 days. Run `quickback login` again to re-authenticate.\n\n## Organizations\n\nAfter login, the CLI auto-selects your organization:\n- **One organization** — automatically set as active.\n- **Multiple organizations** — you're prompted to choose one.\n\nThe active organization is stored in your credentials and sent with compile requests, so the compiler knows which org context to use."
|
|
@@ -115,11 +151,11 @@ export const DOCS = {
|
|
|
115
151
|
},
|
|
116
152
|
"compiler/definitions/actions": {
|
|
117
153
|
"title": "Actions",
|
|
118
|
-
"content": "Actions are custom API endpoints for business logic beyond CRUD operations. They enable workflows, integrations, and complex operations.\n\n## Overview\n\nQuickback supports two types of actions:\n\n| Aspect | Record-Based | Standalone |\n|--------|--------------|------------|\n| Route | `{METHOD} /:id/{actionName}` | Custom `path` or `/{actionName}` |\n| Record fetching | Automatic | None (`record` is `undefined`) |\n| Firewall applied | Yes | No |\n| Preconditions | Supported via `access.record` | Not applicable |\n| Response types | JSON only | JSON, stream, file |\n| Use case | Approve invoice, archive order | AI chat, bulk import, webhooks |\n\n## Defining Actions\n\nActions are defined in a separate `actions.ts` file that references your table:\n\n```typescript\n// features/invoices/actions.ts\n\nexport default defineActions(invoices, {\n approve: {\n description: \"Approve invoice for payment\",\n input: z.object({ notes: z.string().optional() }),\n access: { roles: [\"admin\", \"finance\"] },\n execute: async ({ db, record, ctx }) => {\n // Business logic\n return record;\n },\n },\n});\n```\n\n### Configuration Options\n\n| Option | Required | Description |\n|--------|----------|-------------|\n| `description` | Yes | Human-readable description of the action |\n| `input` | Yes | Zod schema for request validation |\n| `access` | Yes | Access control (roles, record conditions, or function) |\n| `execute` | Yes* | Inline execution function |\n| `handler` | Yes* | File path for complex logic (alternative to `execute`) |\n| `standalone` | No | Set `true` for non-record actions |\n| `path` | No | Custom route path (standalone only) |\n| `method` | No | HTTP method: GET, POST, PUT, PATCH, DELETE (default: POST) |\n| `responseType` | No | Response format: json, stream, file (default: json) |\n| `sideEffects` | No | Hint for AI tools: 'sync', 'async', or 'fire-and-forget' |\n| `unsafe` | No | When `true`, provides `rawDb` (unscoped) to the executor |\n\n*Either `execute` or `handler` is required, not both.\n\n## Record-Based Actions\n\nRecord-based actions operate on an existing record. The record is automatically loaded and validated before your action executes.\n\n**Route pattern:** `{METHOD} /:id/{actionName}`\n\n```\nPOST /invoices/:id/approve\nGET /orders/:id/status\nDELETE /items/:id/archive\n```\n\n### Runtime Flow\n\n1. **Authentication** - User token is validated\n2. **Record Loading** - The record is fetched by ID\n3. **Firewall Check** - Ensures user can access this record\n4. **Access Check** - Validates roles and preconditions\n5. **Input Validation** - Request body validated against Zod schema\n6. **Execution** - Your action handler runs\n7. **Response** - Result is returned to client\n\n### Example: Invoice Approval\n\n```typescript\n// features/invoices/actions.ts\n\nexport default defineActions(invoices, {\n approve: {\n description: \"Approve invoice for payment\",\n input: z.object({\n notes: z.string().optional(),\n }),\n access: {\n roles: [\"admin\", \"finance\"],\n record: { status: { equals: \"pending\" } }, // Precondition\n },\n execute: async ({ db, ctx, record, input }) => {\n const [updated] = await db\n .update(invoices)\n .set({\n status: \"approved\",\n approvedBy: ctx.userId,\n approvedAt: new Date(),\n })\n .where(eq(invoices.id, record.id))\n .returning();\n\n return updated;\n },\n },\n});\n```\n\n### Request Example\n\n```\nPOST /invoices/inv_123/approve\nContent-Type: application/json\n\n{\n \"notes\": \"Approved for Q1 budget\"\n}\n```\n\n### Response Example\n\n```json\n{\n \"data\": {\n \"id\": \"inv_123\",\n \"status\": \"approved\",\n \"approvedBy\": \"user_456\",\n \"approvedAt\": \"2024-01-15T14:30:00Z\"\n }\n}\n```\n\n## Standalone Actions\n\nStandalone actions are independent endpoints that don't require a record context. Use `standalone: true` and optionally specify a custom `path`.\n\n**Route pattern:** Custom `path` or `/{actionName}`\n\n```\nPOST /chat\nGET /reports/summary\nPOST /webhooks/stripe\n```\n\n### Example: AI Chat with Streaming\n\n```typescript\nexport default defineActions(sessions, {\n chat: {\n description: \"Send a message to AI\",\n standalone: true,\n path: \"/chat\",\n method: \"POST\",\n responseType: \"stream\",\n input: z.object({\n message: z.string().min(1).max(2000),\n }),\n access: {\n roles: [\"member\", \"admin\"],\n },\n handler: \"./handlers/chat\",\n },\n});\n```\n\n### Streaming Response Example\n\nFor actions with `responseType: 'stream'`:\n\n```\nContent-Type: text/event-stream\n\ndata: {\"type\": \"start\"}\ndata: {\"type\": \"chunk\", \"content\": \"Hello\"}\ndata: {\"type\": \"chunk\", \"content\": \"! I'm\"}\ndata: {\"type\": \"chunk\", \"content\": \" here to help.\"}\ndata: {\"type\": \"done\"}\n```\n\n### Example: Report Generation\n\n```typescript\nexport default defineActions(invoices, {\n generateReport: {\n description: \"Generate PDF report\",\n standalone: true,\n path: \"/invoices/report\",\n method: \"GET\",\n responseType: \"file\",\n input: z.object({\n startDate: z.string().datetime(),\n endDate: z.string().datetime(),\n }),\n access: { roles: [\"admin\", \"finance\"] },\n handler: \"./handlers/generate-report\",\n },\n});\n```\n\n## Access Configuration\n\nAccess controls who can execute an action and under what conditions.\n\n### Role-Based Access\n\n```typescript\naccess: {\n roles: [\"admin\", \"manager\"] // OR logic: user needs any of these roles\n}\n```\n\n### Record Conditions\n\nFor record-based actions, you can require the record to be in a specific state:\n\n```typescript\naccess: {\n roles: [\"admin\"],\n record: {\n status: { equals: \"pending\" } // Precondition\n }\n}\n```\n\n**Supported operators:**\n\n| Operator | Description |\n|----------|-------------|\n| `equals` | Field must equal value |\n| `notEquals` | Field must not equal value |\n| `in` | Field must be one of the values |\n| `notIn` | Field must not be one of the values |\n\n### Context Substitution\n\nUse `$ctx` to reference the current user's context:\n\n```typescript\naccess: {\n record: {\n ownerId: { equals: \"$ctx.userId\" },\n orgId: { equals: \"$ctx.orgId\" }\n }\n}\n```\n\n### OR/AND Combinations\n\n```typescript\naccess: {\n or: [\n { roles: [\"admin\"] },\n {\n roles: [\"member\"],\n record: { ownerId: { equals: \"$ctx.userId\" } }\n }\n ]\n}\n```\n\n### Function Access\n\nFor complex logic, use an access function:\n\n```typescript\naccess: async (ctx, record) => {\n return ctx.roles.includes('admin') || record.ownerId === ctx.userId;\n}\n```\n\n## Scoped Database\n\nAll actions receive a **scoped `db`** instance that automatically enforces security:\n\n| Operation | Org Scoping | Owner Scoping | Soft Delete Filter | Auto-inject on INSERT |\n|-----------|-------------|---------------|--------------------|-----------------------|\n| `SELECT` | `WHERE organizationId = ?` | `WHERE ownerId = ?` | `WHERE deletedAt IS NULL` | n/a |\n| `INSERT` | n/a | n/a | n/a | `organizationId`, `ownerId` from ctx |\n| `UPDATE` | `WHERE organizationId = ?` | `WHERE ownerId = ?` | `WHERE deletedAt IS NULL` | n/a |\n| `DELETE` | `WHERE organizationId = ?` | `WHERE ownerId = ?` | `WHERE deletedAt IS NULL` | n/a |\n\nScoping is duck-typed at runtime — tables with an `organizationId` column get org scoping, tables with `ownerId` get owner scoping, tables with `deletedAt` get soft delete filtering.\n\n```typescript\nexecute: async ({ db, ctx, input }) => {\n // This query automatically includes WHERE organizationId = ? AND ownerId = ? AND deletedAt IS NULL\n const items = await db.select().from(claims).where(eq(claims.status, 'active'));\n\n // Inserts automatically include organizationId and ownerId\n await db.insert(claims).values({ title: input.title });\n\n return items;\n}\n```\n\n**Not enforced** in scoped DB (by design):\n- **Guards** — actions ARE the authorized way to modify guarded fields\n- **Masking** — actions are backend code that may need raw data\n- **Access** — already checked before action execution\n\n### Unsafe Mode\n\nActions that need to bypass security (e.g., cross-org admin queries) can declare `unsafe: true`:\n\n```typescript\nexport default defineActions(invoices, {\n adminReport: {\n description: \"Generate cross-org report\",\n unsafe: true,\n input: z.object({ startDate: z.string() }),\n access: { roles: [\"admin\"] },\n execute: async ({ db, rawDb, ctx, input }) => {\n // db is still scoped (safety net)\n // rawDb bypasses all security — only available with unsafe: true\n const allOrgs = await rawDb.select().from(invoices);\n return allOrgs;\n },\n },\n});\n```\n\nWithout `unsafe: true`, `rawDb` is `undefined` in the executor params.\n\n## Handler Files\n\nFor complex actions, separate the logic into handler files.\n\n### When to Use Handler Files\n\n- Complex business logic spanning multiple operations\n- External API integrations\n- File generation or processing\n- Logic reused across multiple actions\n\n### Handler Structure\n\n```typescript\n// handlers/generate-report.ts\n\nexport const execute: ActionExecutor = async ({ db, ctx, input, services }) => {\n const invoices = await db\n .select()\n .from(invoicesTable)\n .where(between(invoicesTable.createdAt, input.startDate, input.endDate));\n\n const pdf = await services.pdf.generate(invoices);\n\n return {\n file: pdf,\n filename: `invoices-${input.startDate}-${input.endDate}.pdf`,\n contentType: 'application/pdf',\n };\n};\n```\n\n### Executor Parameters\n\n```typescript\ninterface ActionExecutorParams {\n db: DrizzleDB; // Scoped database (auto-applies org/owner/soft-delete filters)\n rawDb?: DrizzleDB; // Raw database (only available when unsafe: true)\n ctx: AppContext; // User context (userId, roles, orgId)\n record?: TRecord; // The record (record-based only, undefined for standalone)\n input: TInput; // Validated input from Zod schema\n services: TServices; // Configured integrations (billing, notifications, etc.)\n c: HonoContext; // Raw Hono context for advanced use\n auditFields: object; // { createdAt, modifiedAt } timestamps\n}\n```\n\n## HTTP API Reference\n\n### Request Format\n\n| Method | Input Source | Use Case |\n|--------|--------------|----------|\n| `GET` | Query parameters | Read-only operations, fetching data |\n| `POST` | JSON body | Default, state-changing operations |\n| `PUT` | JSON body | Full replacement operations |\n| `PATCH` | JSON body | Partial updates |\n| `DELETE` | JSON body | Deletion with optional payload |\n\n```typescript\n// GET action - input comes from query params\ngetStatus: {\n method: \"GET\",\n input: z.object({ format: z.string().optional() }),\n // Called as: GET /invoices/:id/getStatus?format=detailed\n}\n\n// POST action (default) - input comes from JSON body\napprove: {\n // method: \"POST\" is implied\n input: z.object({ notes: z.string().optional() }),\n // Called as: POST /invoices/:id/approve with JSON body\n}\n```\n\n### Response Formats\n\n| Type | Content-Type | Use Case |\n|------|--------------|----------|\n| `json` | `application/json` | Standard API responses (default) |\n| `stream` | `text/event-stream` | Real-time streaming (AI chat, live updates) |\n| `file` | Varies | File downloads (reports, exports) |\n\n### Error Codes\n\n| Status | Description |\n|--------|-------------|\n| `400` | Invalid input / validation error |\n| `401` | Not authenticated |\n| `403` | Access check failed (role or precondition) |\n| `404` | Record not found (record-based actions) |\n| `500` | Handler execution error |\n\n### Validation Error Response\n\n```json\n{\n \"error\": \"Invalid request data\",\n \"layer\": \"validation\",\n \"code\": \"VALIDATION_FAILED\",\n \"details\": {\n \"fields\": {\n \"amount\": \"Expected positive number\"\n }\n },\n \"hint\": \"Check the input schema for this action\"\n}\n```\n\n### Throwing Custom Errors\n\n```typescript\nexecute: async ({ ctx, record, input }) => {\n if (record.balance < input.amount) {\n throw new ActionError('INSUFFICIENT_FUNDS', 'Not enough balance', 400);\n }\n // ... continue\n}\n```\n\n## Protected Fields\n\nActions can modify fields that are protected from regular CRUD operations:\n\n```typescript\n// In resource.ts\nguards: {\n protected: {\n status: [\"approve\", \"reject\"], // Only these actions can modify status\n amount: [\"reviseAmount\"],\n }\n}\n```\n\nThis allows the `approve` action to set `status = \"approved\"` even though the field is protected from regular PATCH requests.\n\n## Examples\n\n### Invoice Approval (Record-Based)\n\n```typescript\nexport default defineActions(invoices, {\n approve: {\n description: \"Approve invoice for payment\",\n input: z.object({ notes: z.string().optional() }),\n access: {\n roles: [\"admin\", \"finance\"],\n record: { status: { equals: \"pending\" } },\n },\n execute: async ({ db, ctx, record, input }) => {\n const [updated] = await db\n .update(invoices)\n .set({\n status: \"approved\",\n approvedBy: ctx.userId,\n notes: input.notes,\n })\n .where(eq(invoices.id, record.id))\n .returning();\n return updated;\n },\n },\n});\n```\n\n### AI Chat (Standalone with Streaming)\n\n```typescript\nexport default defineActions(sessions, {\n chat: {\n description: \"Send a message to AI assistant\",\n standalone: true,\n path: \"/chat\",\n responseType: \"stream\",\n input: z.object({\n message: z.string().min(1).max(2000),\n }),\n access: { roles: [\"member\", \"admin\"] },\n handler: \"./handlers/chat\",\n },\n});\n```\n\n### Bulk Import (Standalone, No Record)\n\n```typescript\nexport default defineActions(contacts, {\n bulkImport: {\n description: \"Import contacts from CSV\",\n standalone: true,\n path: \"/contacts/import\",\n input: z.object({\n data: z.array(z.object({\n email: z.string().email(),\n name: z.string(),\n })),\n }),\n access: { roles: [\"admin\"] },\n execute: async ({ db, input }) => {\n const inserted = await db\n .insert(contacts)\n .values(input.data)\n .returning();\n return { imported: inserted.length };\n },\n },\n});\n```"
|
|
154
|
+
"content": "Actions are custom API endpoints for business logic beyond CRUD operations. They enable workflows, integrations, and complex operations.\n\n## Overview\n\nQuickback supports two types of actions:\n\n| Aspect | Record-Based | Standalone |\n|--------|--------------|------------|\n| Route | `{METHOD} /:id/{actionName}` | Custom `path` or `/{actionName}` |\n| Record fetching | Automatic | None (`record` is `undefined`) |\n| Firewall applied | Yes | No |\n| Preconditions | Supported via `access.record` | Not applicable |\n| Response types | JSON only | JSON, stream, file |\n| Use case | Approve invoice, archive order | AI chat, bulk import, webhooks |\n\n## Defining Actions\n\nActions are defined in a separate `actions.ts` file that references your table:\n\n```typescript\n// features/invoices/actions.ts\n\nexport default defineActions(invoices, {\n approve: {\n description: \"Approve invoice for payment\",\n input: z.object({ notes: z.string().optional() }),\n access: { roles: [\"admin\", \"finance\"] },\n execute: async ({ db, record, ctx }) => {\n // Business logic\n return record;\n },\n },\n});\n```\n\n### Configuration Options\n\n| Option | Required | Description |\n|--------|----------|-------------|\n| `description` | Yes | Human-readable description of the action |\n| `input` | Yes | Zod schema for request validation |\n| `access` | Yes | Access control (roles, record conditions, or function) |\n| `execute` | Yes* | Inline execution function |\n| `handler` | Yes* | File path for complex logic (alternative to `execute`) |\n| `standalone` | No | Set `true` for non-record actions |\n| `path` | No | Custom route path (standalone only) |\n| `method` | No | HTTP method: GET, POST, PUT, PATCH, DELETE (default: POST) |\n| `responseType` | No | Response format: json, stream, file (default: json) |\n| `sideEffects` | No | Hint for AI tools: 'sync', 'async', or 'fire-and-forget' |\n| `unsafe` | No | When `true`, provides `rawDb` (unscoped) to the executor |\n\n*Either `execute` or `handler` is required, not both.\n\n## Record-Based Actions\n\nRecord-based actions operate on an existing record. The record is automatically loaded and validated before your action executes.\n\n**Route pattern:** `{METHOD} /:id/{actionName}`\n\n```\nPOST /invoices/:id/approve\nGET /orders/:id/status\nDELETE /items/:id/archive\n```\n\n### Runtime Flow\n\n1. **Authentication** - User token is validated\n2. **Record Loading** - The record is fetched by ID\n3. **Firewall Check** - Ensures user can access this record\n4. **Access Check** - Validates roles and preconditions\n5. **Input Validation** - Request body validated against Zod schema\n6. **Execution** - Your action handler runs\n7. **Response** - Result is returned to client\n\n### Example: Invoice Approval\n\n```typescript\n// features/invoices/actions.ts\n\nexport default defineActions(invoices, {\n approve: {\n description: \"Approve invoice for payment\",\n input: z.object({\n notes: z.string().optional(),\n }),\n access: {\n roles: [\"admin\", \"finance\"],\n record: { status: { equals: \"pending\" } }, // Precondition\n },\n execute: async ({ db, ctx, record, input }) => {\n const [updated] = await db\n .update(invoices)\n .set({\n status: \"approved\",\n approvedBy: ctx.userId,\n approvedAt: new Date(),\n })\n .where(eq(invoices.id, record.id))\n .returning();\n\n return updated;\n },\n },\n});\n```\n\n### Request Example\n\n```\nPOST /invoices/inv_123/approve\nContent-Type: application/json\n\n{\n \"notes\": \"Approved for Q1 budget\"\n}\n```\n\n### Response Example\n\n```json\n{\n \"data\": {\n \"id\": \"inv_123\",\n \"status\": \"approved\",\n \"approvedBy\": \"user_456\",\n \"approvedAt\": \"2024-01-15T14:30:00Z\"\n }\n}\n```\n\n## Standalone Actions\n\nStandalone actions are independent endpoints that don't require a record context. Use `standalone: true` and optionally specify a custom `path`.\n\n**Route pattern:** Custom `path` or `/{actionName}`\n\n```\nPOST /chat\nGET /reports/summary\nPOST /webhooks/stripe\n```\n\n### Example: AI Chat with Streaming\n\n```typescript\nexport default defineActions(sessions, {\n chat: {\n description: \"Send a message to AI\",\n standalone: true,\n path: \"/chat\",\n method: \"POST\",\n responseType: \"stream\",\n input: z.object({\n message: z.string().min(1).max(2000),\n }),\n access: {\n roles: [\"member\", \"admin\"],\n },\n handler: \"./handlers/chat\",\n },\n});\n```\n\n### Streaming Response Example\n\nFor actions with `responseType: 'stream'`:\n\n```\nContent-Type: text/event-stream\n\ndata: {\"type\": \"start\"}\ndata: {\"type\": \"chunk\", \"content\": \"Hello\"}\ndata: {\"type\": \"chunk\", \"content\": \"! I'm\"}\ndata: {\"type\": \"chunk\", \"content\": \" here to help.\"}\ndata: {\"type\": \"done\"}\n```\n\n### Example: Report Generation\n\n```typescript\nexport default defineActions(invoices, {\n generateReport: {\n description: \"Generate PDF report\",\n standalone: true,\n path: \"/invoices/report\",\n method: \"GET\",\n responseType: \"file\",\n input: z.object({\n startDate: z.string().datetime(),\n endDate: z.string().datetime(),\n }),\n access: { roles: [\"admin\", \"finance\"] },\n handler: \"./handlers/generate-report\",\n },\n});\n```\n\n## Access Configuration\n\nAccess controls who can execute an action and under what conditions.\n\n### Role-Based Access\n\n```typescript\naccess: {\n roles: [\"admin\", \"manager\"] // OR logic: user needs any of these roles\n}\n```\n\n### Record Conditions\n\nFor record-based actions, you can require the record to be in a specific state:\n\n```typescript\naccess: {\n roles: [\"admin\"],\n record: {\n status: { equals: \"pending\" } // Precondition\n }\n}\n```\n\n**Supported operators:**\n\n| Operator | Description |\n|----------|-------------|\n| `equals` | Field must equal value |\n| `notEquals` | Field must not equal value |\n| `in` | Field must be one of the values |\n| `notIn` | Field must not be one of the values |\n\n### Context Substitution\n\nUse `$ctx` to reference the current user's context:\n\n```typescript\naccess: {\n record: {\n ownerId: { equals: \"$ctx.userId\" },\n orgId: { equals: \"$ctx.orgId\" }\n }\n}\n```\n\n### OR/AND Combinations\n\n```typescript\naccess: {\n or: [\n { roles: [\"admin\"] },\n {\n roles: [\"member\"],\n record: { ownerId: { equals: \"$ctx.userId\" } }\n }\n ]\n}\n```\n\n### Function Access\n\nFor complex logic, use an access function:\n\n```typescript\naccess: async (ctx, record) => {\n return ctx.roles.includes('admin') || record.ownerId === ctx.userId;\n}\n```\n\n## Scoped Database\n\nAll actions receive a **scoped `db`** instance that automatically enforces security:\n\n| Operation | Org Scoping | Owner Scoping | Soft Delete Filter | Auto-inject on INSERT |\n|-----------|-------------|---------------|--------------------|-----------------------|\n| `SELECT` | `WHERE organizationId = ?` | `WHERE ownerId = ?` | `WHERE deletedAt IS NULL` | n/a |\n| `INSERT` | n/a | n/a | n/a | `organizationId`, `ownerId` from ctx |\n| `UPDATE` | `WHERE organizationId = ?` | `WHERE ownerId = ?` | `WHERE deletedAt IS NULL` | n/a |\n| `DELETE` | `WHERE organizationId = ?` | `WHERE ownerId = ?` | `WHERE deletedAt IS NULL` | n/a |\n\nScoping is duck-typed at runtime — tables with an `organizationId` column get org scoping, tables with `ownerId` get owner scoping, tables with `deletedAt` get soft delete filtering.\n\n```typescript\nexecute: async ({ db, ctx, input }) => {\n // This query automatically includes WHERE organizationId = ? AND ownerId = ? AND deletedAt IS NULL\n const items = await db.select().from(claims).where(eq(claims.status, 'active'));\n\n // Inserts automatically include organizationId and ownerId\n await db.insert(claims).values({ title: input.title });\n\n return items;\n}\n```\n\n**Not enforced** in scoped DB (by design):\n- **Guards** — actions ARE the authorized way to modify guarded fields\n- **Masking** — actions are backend code that may need raw data\n- **Access** — already checked before action execution\n\n### Unsafe Mode\n\nActions that need to bypass security (e.g., cross-org admin queries) can declare `unsafe: true`:\n\n```typescript\nexport default defineActions(invoices, {\n adminReport: {\n description: \"Generate cross-org report\",\n unsafe: true,\n input: z.object({ startDate: z.string() }),\n access: { roles: [\"admin\"] },\n execute: async ({ db, rawDb, ctx, input }) => {\n // db is still scoped (safety net)\n // rawDb bypasses all security — only available with unsafe: true\n const allOrgs = await rawDb.select().from(invoices);\n return allOrgs;\n },\n },\n});\n```\n\nWithout `unsafe: true`, `rawDb` is `undefined` in the executor params.\n\n## Handler Files\n\nFor complex actions, separate the logic into handler files.\n\n### When to Use Handler Files\n\n- Complex business logic spanning multiple operations\n- External API integrations\n- File generation or processing\n- Logic reused across multiple actions\n\n### Handler Structure\n\n```typescript\n// handlers/generate-report.ts\n\nexport const execute: ActionExecutor = async ({ db, ctx, input, services }) => {\n const invoices = await db\n .select()\n .from(invoicesTable)\n .where(between(invoicesTable.createdAt, input.startDate, input.endDate));\n\n const pdf = await services.pdf.generate(invoices);\n\n return {\n file: pdf,\n filename: `invoices-${input.startDate}-${input.endDate}.pdf`,\n contentType: 'application/pdf',\n };\n};\n```\n\n### Importing Tables\n\nHandler files can import tables from their own feature or other features. The compiler generates alias files for each table, so you import by the table's file name:\n\n```typescript\n// handlers/post-entry.ts\n\n// Same feature — import from parent directory using the table's file name\n\n// Cross-feature — go up to the features directory, then into the other feature\n\nexport const execute: ActionExecutor = async ({ db, ctx, input, c }) => {\n const [account] = await db\n .select()\n .from(ledgerAccounts)\n .where(eq(ledgerAccounts.id, input.accountId))\n .limit(1);\n\n if (!account) {\n return c.json({ error: 'Account not found', code: 'NOT_FOUND' }, 404);\n }\n\n // ... continue with business logic\n};\n```\n\n**Path pattern from `features/{name}/handlers/`:**\n- Same feature table: `../{table-file-name}` (e.g., `../invoices`)\n- Other feature table: `../../{other-feature}/{table-file-name}` (e.g., `../../accounts/ledger-accounts`)\n- Generated lib files: `../../../lib/{module}` (e.g., `../../../lib/realtime`, `../../../lib/webhooks`)\n\n### Executor Parameters\n\n```typescript\ninterface ActionExecutorParams {\n db: DrizzleDB; // Scoped database (auto-applies org/owner/soft-delete filters)\n rawDb?: DrizzleDB; // Raw database (only available when unsafe: true)\n ctx: AppContext; // User context (userId, roles, orgId)\n record?: TRecord; // The record (record-based only, undefined for standalone)\n input: TInput; // Validated input from Zod schema\n services: TServices; // Configured integrations (billing, notifications, etc.)\n c: HonoContext; // Raw Hono context for advanced use\n auditFields: object; // { createdAt, modifiedAt } timestamps\n}\n```\n\n## HTTP API Reference\n\n### Request Format\n\n| Method | Input Source | Use Case |\n|--------|--------------|----------|\n| `GET` | Query parameters | Read-only operations, fetching data |\n| `POST` | JSON body | Default, state-changing operations |\n| `PUT` | JSON body | Full replacement operations |\n| `PATCH` | JSON body | Partial updates |\n| `DELETE` | JSON body | Deletion with optional payload |\n\n```typescript\n// GET action - input comes from query params\ngetStatus: {\n method: \"GET\",\n input: z.object({ format: z.string().optional() }),\n // Called as: GET /invoices/:id/getStatus?format=detailed\n}\n\n// POST action (default) - input comes from JSON body\napprove: {\n // method: \"POST\" is implied\n input: z.object({ notes: z.string().optional() }),\n // Called as: POST /invoices/:id/approve with JSON body\n}\n```\n\n### Response Formats\n\n| Type | Content-Type | Use Case |\n|------|--------------|----------|\n| `json` | `application/json` | Standard API responses (default) |\n| `stream` | `text/event-stream` | Real-time streaming (AI chat, live updates) |\n| `file` | Varies | File downloads (reports, exports) |\n\n### Error Codes\n\n| Status | Description |\n|--------|-------------|\n| `400` | Invalid input / validation error |\n| `401` | Not authenticated |\n| `403` | Access check failed (role or precondition) |\n| `404` | Record not found (record-based actions) |\n| `500` | Handler execution error |\n\n### Validation Error Response\n\n```json\n{\n \"error\": \"Invalid request data\",\n \"layer\": \"validation\",\n \"code\": \"VALIDATION_FAILED\",\n \"details\": {\n \"fields\": {\n \"amount\": \"Expected positive number\"\n }\n },\n \"hint\": \"Check the input schema for this action\"\n}\n```\n\n### Error Handling\n\n**Option 1: Return a JSON error response** (recommended for most cases)\n\nSince action handlers receive the Hono context (`c`), you can return error responses directly:\n\n```typescript\nexecute: async ({ ctx, record, input, c }) => {\n if (record.balance < input.amount) {\n return c.json({\n error: 'Not enough balance',\n code: 'INSUFFICIENT_FUNDS',\n details: { required: input.amount, available: record.balance },\n }, 400);\n }\n // ... continue\n}\n```\n\n**Option 2: Throw an ActionError**\n\n```typescript\n\nexecute: async ({ ctx, record, input }) => {\n if (record.balance < input.amount) {\n throw new ActionError('Not enough balance', 'INSUFFICIENT_FUNDS', 400, {\n required: input.amount,\n available: record.balance,\n });\n }\n // ... continue\n}\n```\n\nThe `ActionError` constructor signature is `(message, code, statusCode, details?)`.\n\n## Protected Fields\n\nActions can modify fields that are protected from regular CRUD operations:\n\n```typescript\n// In resource.ts\nguards: {\n protected: {\n status: [\"approve\", \"reject\"], // Only these actions can modify status\n amount: [\"reviseAmount\"],\n }\n}\n```\n\nThis allows the `approve` action to set `status = \"approved\"` even though the field is protected from regular PATCH requests.\n\n## Examples\n\n### Invoice Approval (Record-Based)\n\n```typescript\nexport default defineActions(invoices, {\n approve: {\n description: \"Approve invoice for payment\",\n input: z.object({ notes: z.string().optional() }),\n access: {\n roles: [\"admin\", \"finance\"],\n record: { status: { equals: \"pending\" } },\n },\n execute: async ({ db, ctx, record, input }) => {\n const [updated] = await db\n .update(invoices)\n .set({\n status: \"approved\",\n approvedBy: ctx.userId,\n notes: input.notes,\n })\n .where(eq(invoices.id, record.id))\n .returning();\n return updated;\n },\n },\n});\n```\n\n### AI Chat (Standalone with Streaming)\n\n```typescript\nexport default defineActions(sessions, {\n chat: {\n description: \"Send a message to AI assistant\",\n standalone: true,\n path: \"/chat\",\n responseType: \"stream\",\n input: z.object({\n message: z.string().min(1).max(2000),\n }),\n access: { roles: [\"member\", \"admin\"] },\n handler: \"./handlers/chat\",\n },\n});\n```\n\n### Bulk Import (Standalone, No Record)\n\n```typescript\nexport default defineActions(contacts, {\n bulkImport: {\n description: \"Import contacts from CSV\",\n standalone: true,\n path: \"/contacts/import\",\n input: z.object({\n data: z.array(z.object({\n email: z.string().email(),\n name: z.string(),\n })),\n }),\n access: { roles: [\"admin\"] },\n execute: async ({ db, input }) => {\n const inserted = await db\n .insert(contacts)\n .values(input.data)\n .returning();\n return { imported: inserted.length };\n },\n },\n});\n```"
|
|
119
155
|
},
|
|
120
156
|
"compiler/definitions/firewall": {
|
|
121
157
|
"title": "Firewall - Data Isolation",
|
|
122
|
-
"content": "The firewall generates WHERE clauses automatically to isolate data by user, organization, or team.\n\n## Basic Usage\n\n```typescript\n// features/rooms/rooms.ts\n\nexport const rooms = sqliteTable('rooms', {\n id: text('id').primaryKey(),\n name: text('name').notNull(),\n organizationId: text('organization_id').notNull(),\n});\n\nexport default defineTable(rooms, {\n firewall: { organization: {} }, // Isolate by organization\n // ... guards, crud\n});\n```\n\n## Configuration Options\n\n```typescript\nfirewall: {\n // User-level ownership (personal data)\n owner?: {\n column?: string; // Default: 'ownerId' or 'owner_id'\n source?: string; // Default: 'ctx.userId'\n mode?: 'required' | 'optional'; // Default: 'required'\n };\n\n // Organization-level ownership\n organization?: {\n column?: string; // Default: 'organizationId' or 'organization_id'\n source?: string; // Default: 'ctx.activeOrgId'\n };\n\n // Team-level ownership\n team?: {\n column?: string; // Default: 'teamId' or 'team_id'\n source?: string; // Default: 'ctx.activeTeamId'\n };\n\n // Soft delete filtering\n softDelete?: {\n column?: string; // Default: 'deletedAt'\n };\n\n // Opt-out for public tables (cannot combine with ownership)\n exception?: boolean;\n}\n```\n\n## Context Sources\n\nThe firewall pulls ownership values from the request context:\n\n| Scope | Default Source | Description |\n|-------|----------------|-------------|\n| `owner` | `ctx.userId` | Current authenticated user's ID |\n| `organization` | `ctx.activeOrgId` | User's active organization ID |\n| `team` | `ctx.activeTeamId` | User's active team ID |\n\nThese context values are populated by the auth middleware from the user's session.\n```\n\n## Auto-Detection\n\nQuickback automatically detects firewall scope based on your column names:\n\n| Column Name | Detected Scope |\n|-------------|----------------|\n| `organizationId` or `organization_id` | `organization` |\n| `ownerId` or `owner_id` | `owner` |\n| `teamId` or `team_id` | `team` |\n| `deletedAt` or `deleted_at` | `softDelete` |\n\nThis means you often don't need to specify column names:\n\n```typescript\n// Quickback detects organizationId column automatically\nfirewall: { organization: {} }\n\n// Quickback detects ownerId column automatically\nfirewall: { owner: {} }\n```\n\n## Common Patterns\n\n```typescript\n// Public/system table - no filtering\nfirewall: { exception: true }\n\n// Organization-scoped data (auto-detected from organizationId column)\nfirewall: { organization: {} }\n\n// Personal user data (auto-detected from ownerId column)\nfirewall: { owner: {} }\n\n// Org data with optional owner filtering\nfirewall: {\n organization: {},\n owner: { mode: 'optional' }\n}\n\n// With soft delete (auto-detected from deletedAt column)\nfirewall: {\n organization: {},\n softDelete: {}\n}\n```\n\n## Rules\n\n- Every resource MUST have at least one ownership scope OR `exception: true`\n- Cannot mix `exception: true` with ownership scopes\n\n## Why Can't You Mix `exception` with Ownership?\n\nThey represent opposite intentions:\n- `exception: true` = \"Generate NO WHERE clauses, data is public/global\"\n- Ownership scopes = \"Generate WHERE clauses to filter data\"\n\nCombining them would be contradictory - you can't both filter and not filter.\n\n## Handling \"Some Public, Some Private\" Data\n\nIf you need records that are sometimes public and sometimes scoped, you have two options:\n\n### Option 1: Two Separate Tables\n\nSplit into two tables - one public, one scoped:\n\n```typescript\n// features/templates/template-library.ts - Public templates\nexport default defineTable(templateLibrary, {\n firewall: { exception: true },\n // ...\n});\n\n// features/templates/user-templates.ts - User's custom templates\nexport default defineTable(userTemplates, {\n firewall: { owner: {} },\n // ...\n});\n```\n\n### Option 2: Use Access Control Instead\n\nKeep ownership scope but make access permissive, then control visibility via `access`:\n\n```typescript\n// features/documents/documents.ts\nexport default defineTable(documents, {\n firewall: {\n organization: {}, // Still scoped to org\n },\n crud: {\n list: {\n // Anyone in the org can list, but they see different things\n // based on a \"visibility\" field you check in your app logic\n access: { roles: [\"owner\", \"admin\", \"member\"] },\n },\n get: {\n // Use record conditions to allow public docs OR owned docs\n access: {\n or: [\n { record: { visibility: { equals: \"public\" } } },\n { record: { ownerId: { equals: \"$ctx.userId\" } } },\n { roles: [\"admin\"] }\n ]\n }\n },\n },\n // ...\n});\n```\n\n### Which to Choose?\n\n| Scenario | Recommendation |\n|----------|----------------|\n| Truly global data (app config, public templates) | `exception: true` in separate resource |\n| \"Public within org\" but still org-isolated | Ownership scope + permissive access rules |\n| User can toggle their own data public/private | Ownership scope + `visibility` field + access conditions |"
|
|
158
|
+
"content": "The firewall generates WHERE clauses automatically to isolate data by user, organization, or team.\n\n## Basic Usage\n\n```typescript\n// features/rooms/rooms.ts\n\nexport const rooms = sqliteTable('rooms', {\n id: text('id').primaryKey(),\n name: text('name').notNull(),\n organizationId: text('organization_id').notNull(),\n});\n\nexport default defineTable(rooms, {\n firewall: { organization: {} }, // Isolate by organization\n // ... guards, crud\n});\n```\n\n## Configuration Options\n\n```typescript\nfirewall: {\n // User-level ownership (personal data)\n owner?: {\n column?: string; // Default: 'ownerId' or 'owner_id'\n source?: string; // Default: 'ctx.userId'\n mode?: 'required' | 'optional'; // Default: 'required'\n };\n\n // Organization-level ownership\n organization?: {\n column?: string; // Default: 'organizationId' or 'organization_id'\n source?: string; // Default: 'ctx.activeOrgId'\n };\n\n // Team-level ownership\n team?: {\n column?: string; // Default: 'teamId' or 'team_id'\n source?: string; // Default: 'ctx.activeTeamId'\n };\n\n // Soft delete filtering\n softDelete?: {\n column?: string; // Default: 'deletedAt'\n };\n\n // Error reporting mode for firewall violations\n // 'reveal' (default): 403 with structured error message\n // 'hide': Generic 404 (prevents record enumeration)\n errorMode?: 'reveal' | 'hide';\n\n // Opt-out for public tables (cannot combine with ownership)\n exception?: boolean;\n}\n```\n\n## Context Sources\n\nThe firewall pulls ownership values from the request context:\n\n| Scope | Default Source | Description |\n|-------|----------------|-------------|\n| `owner` | `ctx.userId` | Current authenticated user's ID |\n| `organization` | `ctx.activeOrgId` | User's active organization ID |\n| `team` | `ctx.activeTeamId` | User's active team ID |\n\nThese context values are populated by the auth middleware from the user's session.\n```\n\n## Auto-Detection\n\nQuickback automatically detects firewall scope based on your column names:\n\n| Column Name | Detected Scope |\n|-------------|----------------|\n| `organizationId` or `organization_id` | `organization` |\n| `ownerId` or `owner_id` | `owner` |\n| `teamId` or `team_id` | `team` |\n| `deletedAt` or `deleted_at` | `softDelete` |\n\nThis means you often don't need to specify column names:\n\n```typescript\n// Quickback detects organizationId column automatically\nfirewall: { organization: {} }\n\n// Quickback detects ownerId column automatically\nfirewall: { owner: {} }\n```\n\n## Common Patterns\n\n```typescript\n// Public/system table - no filtering\nfirewall: { exception: true }\n\n// Organization-scoped data (auto-detected from organizationId column)\nfirewall: { organization: {} }\n\n// Personal user data (auto-detected from ownerId column)\nfirewall: { owner: {} }\n\n// Org data with optional owner filtering\nfirewall: {\n organization: {},\n owner: { mode: 'optional' }\n}\n\n// With soft delete (auto-detected from deletedAt column)\nfirewall: {\n organization: {},\n softDelete: {}\n}\n```\n\n## Error Responses\n\nWhen a record isn't found behind the firewall (wrong org, soft-deleted, or genuinely doesn't exist), the error response depends on `errorMode`:\n\n### Reveal Mode (Default)\n\nReturns **403** with a structured error:\n\n```json\n{\n \"error\": \"Record not found or not accessible\",\n \"layer\": \"firewall\",\n \"code\": \"FIREWALL_NOT_FOUND\",\n \"hint\": \"Check the record ID and your organization membership\"\n}\n```\n\nThis is developer-friendly and helps debug access issues during development.\n\n### Hide Mode\n\nReturns **404** with a generic error:\n\n```json\n{\n \"error\": \"Not found\",\n \"code\": \"NOT_FOUND\"\n}\n```\n\nUse `errorMode: 'hide'` for security-hardened deployments where you don't want attackers to distinguish between \"record exists but you can't access it\" and \"record doesn't exist\":\n\n```typescript\nfirewall: {\n organization: {},\n errorMode: 'hide', // Opaque 404s prevent record enumeration\n}\n```\n\n## Rules\n\n- Every resource MUST have at least one ownership scope OR `exception: true`\n- Cannot mix `exception: true` with ownership scopes\n\n## Why Can't You Mix `exception` with Ownership?\n\nThey represent opposite intentions:\n- `exception: true` = \"Generate NO WHERE clauses, data is public/global\"\n- Ownership scopes = \"Generate WHERE clauses to filter data\"\n\nCombining them would be contradictory - you can't both filter and not filter.\n\n## Handling \"Some Public, Some Private\" Data\n\nIf you need records that are sometimes public and sometimes scoped, you have two options:\n\n### Option 1: Two Separate Tables\n\nSplit into two tables - one public, one scoped:\n\n```typescript\n// features/templates/template-library.ts - Public templates\nexport default defineTable(templateLibrary, {\n firewall: { exception: true },\n // ...\n});\n\n// features/templates/user-templates.ts - User's custom templates\nexport default defineTable(userTemplates, {\n firewall: { owner: {} },\n // ...\n});\n```\n\n### Option 2: Use Access Control Instead\n\nKeep ownership scope but make access permissive, then control visibility via `access`:\n\n```typescript\n// features/documents/documents.ts\nexport default defineTable(documents, {\n firewall: {\n organization: {}, // Still scoped to org\n },\n crud: {\n list: {\n // Anyone in the org can list, but they see different things\n // based on a \"visibility\" field you check in your app logic\n access: { roles: [\"owner\", \"admin\", \"member\"] },\n },\n get: {\n // Use record conditions to allow public docs OR owned docs\n access: {\n or: [\n { record: { visibility: { equals: \"public\" } } },\n { record: { ownerId: { equals: \"$ctx.userId\" } } },\n { roles: [\"admin\"] }\n ]\n }\n },\n },\n // ...\n});\n```\n\n### Which to Choose?\n\n| Scenario | Recommendation |\n|----------|----------------|\n| Truly global data (app config, public templates) | `exception: true` in separate resource |\n| \"Public within org\" but still org-isolated | Ownership scope + permissive access rules |\n| User can toggle their own data public/private | Ownership scope + `visibility` field + access conditions |"
|
|
123
159
|
},
|
|
124
160
|
"compiler/definitions/guards": {
|
|
125
161
|
"title": "Guards - Field Modification Rules",
|
|
@@ -127,15 +163,15 @@ export const DOCS = {
|
|
|
127
163
|
},
|
|
128
164
|
"compiler/definitions": {
|
|
129
165
|
"title": "Definitions Overview",
|
|
130
|
-
"content": "Before diving into specific features, let's understand how Quickback's pieces connect. This page gives you the mental model for everything that follows.\n\n## The Big Picture\n\nQuickback is a **backend compiler**. You write definition files, and Quickback compiles them into a production-ready API.\n\n1. **You write definitions** - Table files with schema and security config using `defineTable`\n2. **Quickback compiles them** - Analyzes your definitions at build time\n3. **You get a production API** - `GET /rooms`, `POST /rooms`, `PATCH /rooms/:id`, `DELETE /rooms/:id`, batch operations, plus custom actions\n\n## File Structure\n\nYour definitions live in a `definitions/` folder organized by feature:\n\n```\nmy-app/\n├── quickback/\n│ ├── quickback.config.ts # Compiler configuration\n│ └── definitions/\n│ └── features/\n│ └── {feature-name}/\n│ ├── claims.ts # Table + config (defineTable)\n│ ├── claim-versions.ts # Secondary table + config\n│ ├── claim-sources.ts # Internal table (no routes)\n│ ├── actions.ts # Custom actions (optional)\n│ └── handlers/ # Action handlers (optional)\n│ └── my-action.ts\n├── src/ # Generated code (output)\n├── drizzle/ # Generated migrations\n└── package.json\n```\n\n**Table files** use `defineTable` to combine schema and security config:\n\n```typescript\n// features/claims/claims.ts\n\nexport const claims = sqliteTable(\"claims\", {\n id: text(\"id\").primaryKey(),\n organizationId: text(\"organization_id\").notNull(),\n content: text(\"content\").notNull(),\n});\n\nexport default defineTable(claims, {\n firewall: { organization: {} },\n guards: {\n createable: [\"content\"],\n updatable: [\"content\"],\n },\n crud: {\n list: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n get: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n create: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n update: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n delete: { access: { roles: [\"owner\", \"admin\"] } },\n },\n});\n\nexport type Claim = typeof claims.$inferSelect;\n```\n\n**Key points:**\n- Tables with `export default defineTable(...)` → CRUD routes generated\n- Tables without default export → internal/junction tables (no routes)\n- Route path derived from filename: `claim-versions.ts` → `/api/v1/claim-versions`\n\n## The Four Security Layers\n\nEvery API request passes through four security layers, in order:\n\n```\nRequest → Firewall → Access → Guards → Masking → Response\n │ │ │ │\n │ │ │ └── Hide sensitive fields\n │ │ └── Block field modifications\n │ └── Check roles & conditions\n └── Isolate data by owner/org/team\n```\n\n### 1. Firewall (Data Isolation)\n\nThe firewall controls **which records** a user can see. It automatically adds WHERE clauses to every query based on your schema columns:\n\n| Column in Schema | What happens |\n|------------------|--------------|\n| `organization_id` | Data isolated by organization |\n| `user_id` | Data isolated by user (personal data) |\n\nNo manual configuration needed - Quickback applies smart rules based on your schema.\n\n[Learn more about Firewall →](/compiler/definitions/firewall)\n\n### 2. Access (CRUD Permissions)\n\nAccess controls **which operations** a user can perform. It checks roles and record conditions.\n\n```typescript\ncrud: {\n list: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n create: { access: { roles: [\"owner\", \"admin\"] } },\n update: { access: { roles: [\"owner\", \"admin\"] } },\n delete: { access: { roles: [\"owner\", \"admin\"] } },\n}\n```\n\n[Learn more about Access →](/compiler/definitions/access)\n\n### 3. Guards (Field Modification Rules)\n\nGuards control **which fields** can be modified in each operation.\n\n| Guard Type | What it means |\n|------------|---------------|\n| `createable` | Fields that can be set when creating |\n| `updatable` | Fields that can be changed when updating |\n| `protected` | Fields that can only be changed via specific actions |\n| `immutable` | Fields that can never be changed after creation |\n\n[Learn more about Guards →](/compiler/definitions/guards)\n\n### 4. Masking (Data Redaction)\n\nMasking hides sensitive fields from users who shouldn't see them.\n\n```typescript\nmasking: {\n ssn: { type: 'ssn' }, // Shows:
|
|
166
|
+
"content": "Before diving into specific features, let's understand how Quickback's pieces connect. This page gives you the mental model for everything that follows.\n\n## The Big Picture\n\nQuickback is a **backend compiler**. You write definition files, and Quickback compiles them into a production-ready API.\n\n1. **You write definitions** - Table files with schema and security config using `defineTable`\n2. **Quickback compiles them** - Analyzes your definitions at build time\n3. **You get a production API** - `GET /rooms`, `POST /rooms`, `PATCH /rooms/:id`, `DELETE /rooms/:id`, batch operations, plus custom actions\n\n## File Structure\n\nYour definitions live in a `definitions/` folder organized by feature:\n\n```\nmy-app/\n├── quickback/\n│ ├── quickback.config.ts # Compiler configuration\n│ └── definitions/\n│ └── features/\n│ └── {feature-name}/\n│ ├── claims.ts # Table + config (defineTable)\n│ ├── claim-versions.ts # Secondary table + config\n│ ├── claim-sources.ts # Internal table (no routes)\n│ ├── actions.ts # Custom actions (optional)\n│ └── handlers/ # Action handlers (optional)\n│ └── my-action.ts\n├── src/ # Generated code (output)\n├── drizzle/ # Generated migrations\n└── package.json\n```\n\n**Table files** use `defineTable` to combine schema and security config:\n\n```typescript\n// features/claims/claims.ts\n\nexport const claims = sqliteTable(\"claims\", {\n id: text(\"id\").primaryKey(),\n organizationId: text(\"organization_id\").notNull(),\n content: text(\"content\").notNull(),\n});\n\nexport default defineTable(claims, {\n firewall: { organization: {} },\n guards: {\n createable: [\"content\"],\n updatable: [\"content\"],\n },\n crud: {\n list: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n get: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n create: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n update: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n delete: { access: { roles: [\"owner\", \"admin\"] } },\n },\n});\n\nexport type Claim = typeof claims.$inferSelect;\n```\n\n**Key points:**\n- Tables with `export default defineTable(...)` → CRUD routes generated\n- Tables without default export → internal/junction tables (no routes)\n- Route path derived from filename: `claim-versions.ts` → `/api/v1/claim-versions`\n\n## The Four Security Layers\n\nEvery API request passes through four security layers, in order:\n\n```\nRequest → Firewall → Access → Guards → Masking → Response\n │ │ │ │\n │ │ │ └── Hide sensitive fields\n │ │ └── Block field modifications\n │ └── Check roles & conditions\n └── Isolate data by owner/org/team\n```\n\n### 1. Firewall (Data Isolation)\n\nThe firewall controls **which records** a user can see. It automatically adds WHERE clauses to every query based on your schema columns:\n\n| Column in Schema | What happens |\n|------------------|--------------|\n| `organization_id` | Data isolated by organization |\n| `user_id` | Data isolated by user (personal data) |\n\nNo manual configuration needed - Quickback applies smart rules based on your schema.\n\n[Learn more about Firewall →](/compiler/definitions/firewall)\n\n### 2. Access (CRUD Permissions)\n\nAccess controls **which operations** a user can perform. It checks roles and record conditions.\n\n```typescript\ncrud: {\n list: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n create: { access: { roles: [\"owner\", \"admin\"] } },\n update: { access: { roles: [\"owner\", \"admin\"] } },\n delete: { access: { roles: [\"owner\", \"admin\"] } },\n}\n```\n\n[Learn more about Access →](/compiler/definitions/access)\n\n### 3. Guards (Field Modification Rules)\n\nGuards control **which fields** can be modified in each operation.\n\n| Guard Type | What it means |\n|------------|---------------|\n| `createable` | Fields that can be set when creating |\n| `updatable` | Fields that can be changed when updating |\n| `protected` | Fields that can only be changed via specific actions |\n| `immutable` | Fields that can never be changed after creation |\n\n[Learn more about Guards →](/compiler/definitions/guards)\n\n### 4. Masking (Data Redaction)\n\nMasking hides sensitive fields from users who shouldn't see them.\n\n```typescript\nmasking: {\n ssn: { type: 'ssn' }, // Shows: *****1234\n email: { type: 'email' }, // Shows: j***@y*********.com\n salary: { type: 'redact' }, // Shows: [REDACTED]\n}\n```\n\n[Learn more about Masking →](/compiler/definitions/masking)\n\n## How They Work Together\n\n**Scenario:** A member requests `GET /employees/123`\n\n1. **Firewall** checks: Is employee 123 in the user's organization?\n - Yes → Continue\n - No → 404 Not Found (as if it doesn't exist)\n\n2. **Access** checks: Can members perform GET?\n - Yes → Continue\n - No → 403 Forbidden\n\n3. **Guards** don't apply to GET (they're for writes)\n\n4. **Masking** applies: User is a member, not admin\n - SSN: `123-45-6789` → `*****6789`\n - Salary: `85000` → `[REDACTED]`\n\n5. **Response** sent with masked data\n\n## Locked Down by Default\n\nQuickback is **secure by default**. Nothing is accessible until you explicitly allow it.\n\n| Layer | Default | What you must do |\n|-------|---------|------------------|\n| Firewall | AUTO | Auto-detects from `organization_id`/`user_id` columns. Only configure for exceptions. |\n| Access | DENIED | Explicitly define `access` rules with roles |\n| Guards | LOCKED | Explicitly list `createable`, `updatable` fields |\n| Actions | BLOCKED | Explicitly define `access` for each action |\n\n**You must deliberately open each door.** This prevents accidental data exposure.\n\n## Next Steps\n\n1. [Database Schema](/compiler/definitions/schema) — Define your tables\n2. [Firewall](/compiler/definitions/firewall) — Set up data isolation\n3. [Access](/compiler/definitions/access) — Configure CRUD permissions\n4. [Guards](/compiler/definitions/guards) — Control field modifications\n5. [Masking](/compiler/definitions/masking) — Hide sensitive data\n6. [Views](/compiler/definitions/views) — Column-level security\n7. [Validation](/compiler/definitions/validation) — Field validation rules\n8. [Actions](/compiler/definitions/actions) — Add custom business logic"
|
|
131
167
|
},
|
|
132
168
|
"compiler/definitions/masking": {
|
|
133
169
|
"title": "Masking - Field Redaction",
|
|
134
|
-
"content": "Hide sensitive data from unauthorized users while showing it to those with permission.\n\n## Automatic Masking (Secure by Default)\n\nQuickback automatically applies masking to columns that match sensitive naming patterns. This ensures a high security posture even if you don't explicitly configure masking.\n\n### Sensitive Keywords & Default Masks\n\n| Pattern | Default Mask | Description |\n| :--- | :--- | :--- |\n| `email` | `email` | p***@e
|
|
170
|
+
"content": "Hide sensitive data from unauthorized users while showing it to those with permission.\n\n## Automatic Masking (Secure by Default)\n\nQuickback automatically applies masking to columns that match sensitive naming patterns. This ensures a high security posture even if you don't explicitly configure masking.\n\n### Sensitive Keywords & Default Masks\n\n| Pattern | Default Mask | Description |\n| :--- | :--- | :--- |\n| `email` | `email` | p***@e******.com |\n| `phone`, `mobile`, `fax` | `phone` | ******4567 |\n| `ssn`, `socialsecurity`, `nationalid` | `ssn` | *****6789 |\n| `creditcard`, `cc`, `cardnumber`, `cvv` | `creditCard` | ************1234 |\n| `iban` | `creditCard` | ************1234 |\n| `password`, `secret`, `token`, `apikey`, `privatekey` | `redact` | [REDACTED] |\n\n### Build-Time Alerts\n\nIf the compiler auto-detects a sensitive column that you haven't explicitly configured, it will emit a warning:\n\n```bash\n[Warning] Auto-masking enabled for sensitive column \"users.email\". Explicitly configure masking to silence this warning.\n```\n\nTo silence this warning or change the behavior, simply define the field explicitly in your `masking` configuration. Explicit configurations always take precedence over auto-detected defaults.\n\n### Overriding Defaults\n\nIf you want to show a sensitive field to everyone (disable masking) or use a different rule, define it explicitly in the `resource.masking` block:\n\n```typescript\nexport default defineTable(users, {\n masking: {\n // Show email to everyone (overrides auto-masking)\n email: { type: 'email', show: { roles: ['everyone'] } },\n \n // Silence warning but keep secure (owner-only)\n ssn: { type: 'ssn', show: { or: 'owner' } }\n }\n});\n```\n\n## Basic Usage\n\n```typescript\n// features/employees/employees.ts\n\nexport const employees = sqliteTable('employees', {\n id: text('id').primaryKey(),\n name: text('name').notNull(),\n ssn: text('ssn'),\n salary: integer('salary'),\n email: text('email'),\n organizationId: text('organization_id').notNull(),\n});\n\nexport default defineTable(employees, {\n firewall: { organization: {} },\n guards: { createable: [\"name\", \"ssn\", \"salary\", \"email\"], updatable: [\"name\"] },\n masking: {\n ssn: { type: 'ssn', show: { roles: ['hr', 'admin'] } },\n salary: { type: 'redact', show: { roles: ['hr', 'admin'] } },\n email: { type: 'email', show: { or: 'owner' } },\n },\n crud: {\n // ...\n },\n});\n```\n\n## Built-in Mask Types\n\n| Type | Example Input | Masked Output |\n|------|---------------|---------------|\n| `'email'` | `john@yourdomain.com` | `j***@y*********.com` |\n| `'phone'` | `555-123-4567` | `******4567` |\n| `'ssn'` | `123-45-6789` | `*****6789` |\n| `'creditCard'` | `4111111111111111` | `************1111` |\n| `'name'` | `John Smith` | `J*** S****` |\n| `'redact'` | `anything` | `[REDACTED]` |\n| `'custom'` | (your logic) | (your output) |\n\n## Configuration\n\n```typescript\nmasking: {\n // Basic masking - everyone sees masked value\n taxId: { type: 'ssn' },\n\n // Show unmasked to specific roles\n salary: {\n type: 'redact',\n show: { roles: ['admin', 'hr'] }\n },\n\n // Show unmasked to owner (createdBy === ctx.userId)\n email: {\n type: 'email',\n show: { or: 'owner' }\n },\n\n // Custom mask function\n apiKey: {\n type: 'custom',\n mask: (value) => value.slice(0, 4) + '...' + value.slice(-4),\n show: { roles: ['admin'] }\n },\n}\n```\n\n## Show Conditions\n\n```typescript\nshow: {\n roles?: string[]; // Unmasked if user has any of these roles\n or?: 'owner'; // Unmasked if user is the record owner\n}\n```\n\nThe `'owner'` condition compares against the owner column configured in your firewall. If you have `firewall: { owner: {} }`, the owner column (default: `ownerId` or `owner_id`) is used to determine ownership. If no owner firewall is configured, it falls back to `createdBy`.\n\n## Complete Example\n\n```typescript\n// features/employees/employees.ts\nexport default defineTable(employees, {\n firewall: { organization: {} },\n guards: { createable: [\"name\", \"ssn\", \"salary\"], updatable: [\"name\"] },\n masking: {\n ssn: { type: 'ssn', show: { roles: ['hr', 'admin'] } },\n salary: { type: 'redact', show: { roles: ['hr', 'admin'] } },\n personalEmail: { type: 'email', show: { or: 'owner' } },\n bankAccount: {\n type: 'custom',\n mask: (val) => '****' + val.slice(-4),\n show: { roles: ['payroll'] }\n },\n },\n crud: {\n list: { access: { roles: [\"member\", \"admin\"] } },\n get: { access: { roles: [\"member\", \"admin\"] } },\n create: { access: { roles: [\"hr\", \"admin\"] } },\n update: { access: { roles: [\"hr\", \"admin\"] } },\n delete: { access: { roles: [\"admin\"] } },\n },\n});\n```"
|
|
135
171
|
},
|
|
136
172
|
"compiler/definitions/schema": {
|
|
137
173
|
"title": "Database Schema",
|
|
138
|
-
"content": "Quickback uses [Drizzle ORM](https://orm.drizzle.team/) to define your database schema. With `defineTable`, you combine your schema definition and security configuration in a single file.\n\n## Defining Tables with defineTable\n\nEach table gets its own file with schema and config together. Use the Drizzle dialect that matches your target database:\n\n| Target Database | Import From | Table Function |\n|-----------------|-------------|----------------|\n| Cloudflare D1, Turso, SQLite | `drizzle-orm/sqlite-core` | `sqliteTable` |\n| Supabase, Neon, PostgreSQL | `drizzle-orm/pg-core` | `pgTable` |\n| PlanetScale, MySQL | `drizzle-orm/mysql-core` | `mysqlTable` |\n\n```typescript\n// definitions/features/rooms/rooms.ts\n// For D1/SQLite targets:\n\nexport const rooms = sqliteTable('rooms', {\n id: text('id').primaryKey(),\n name: text('name').notNull(),\n description: text('description'),\n capacity: integer('capacity').notNull().default(10),\n roomType: text('room_type').notNull(),\n isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),\n\n // Ownership - for firewall data isolation\n organizationId: text('organization_id').notNull(),\n});\n\nexport default defineTable(rooms, {\n firewall: { organization: {} },\n guards: {\n createable: [\"name\", \"description\", \"capacity\", \"roomType\"],\n updatable: [\"name\", \"description\", \"capacity\"],\n },\n crud: {\n list: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n get: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n create: { access: { roles: [\"owner\", \"admin\"] } },\n update: { access: { roles: [\"owner\", \"admin\"] } },\n delete: { access: { roles: [\"owner\", \"admin\"] } },\n },\n});\n\nexport type Room = typeof rooms.$inferSelect;\n```\n\n## Column Types\n\nDrizzle supports all standard SQL column types:\n\n| Type | Drizzle Function | Example |\n|------|------------------|---------|\n| String | `text()`, `varchar()` | `text('name')` |\n| Integer | `integer()`, `bigint()` | `integer('count')` |\n| Boolean | `boolean()` | `boolean('is_active')` |\n| Timestamp | `timestamp()` | `timestamp('created_at')` |\n| JSON | `json()`, `jsonb()` | `jsonb('metadata')` |\n| UUID | `uuid()` | `uuid('id')` |\n| Decimal | `decimal()`, `numeric()` | `decimal('price', { precision: 10, scale: 2 })` |\n\n## Column Modifiers\n\n```typescript\n// Required field\nname: text('name').notNull()\n\n// Default value\nisActive: boolean('is_active').default(true)\n\n// Primary key\nid: text('id').primaryKey()\n\n// Unique constraint\nemail: text('email').unique()\n\n// Default to current timestamp\ncreatedAt: timestamp('created_at').defaultNow()\n```\n\n## File Organization\n\nOrganize your tables by feature. Each feature directory contains table files:\n\n```\ndefinitions/\n└── features/\n ├── rooms/\n │ ├── rooms.ts # Main table + config\n │ ├── room-bookings.ts # Related table + config\n │ └── actions.ts # Custom actions\n ├── users/\n │ ├── users.ts # Table + config\n │ └── user-preferences.ts # Related table\n └── organizations/\n └── organizations.ts\n```\n\n**Key points:**\n- Tables with `export default defineTable(...)` get CRUD routes generated\n- Tables without a default export are internal (no routes, used for joins/relations)\n- Route paths are derived from filenames: `room-bookings.ts` → `/api/v1/room-bookings`\n\n### defineTable vs defineResource\n\nBoth functions are available:\n\n- **`defineTable`** - The standard function for defining tables with CRUD routes\n- **`defineResource`** - Alias for `defineTable`, useful when thinking in terms of REST resources\n\n```typescript\n// These are equivalent:\nexport default defineTable(rooms, { /* config */ });\nexport default defineResource(rooms, { /* config */ });\n```\n\n## 1 Resource = 1 Security Boundary\n\nEach `defineTable()` call defines a complete, self-contained security boundary. The security config you write — firewall, access, guards, and masking — is compiled into a single resource file that wraps all CRUD routes for that table.\n\nThis is a deliberate design choice. Mixing two resources with different security rules in one configuration would create ambiguity about which firewall, access, or masking rules apply to which table. By keeping it 1:1, there's never any question.\n\n| Scenario | What to do |\n|----------|------------|\n| Table needs its own API routes + security | Own file with `defineTable()` |\n| Table is internal/supporting (no direct API) | Extra `.ts` file in the parent feature directory, no `defineTable()` |\n\nA supporting table without `defineTable()` is useful when it's accessed internally — by action handlers, joins, or background jobs — but should never be directly exposed as its own API endpoint.\n\n## Internal Tables (No Routes)\n\nFor junction tables or internal data structures that shouldn't have API routes, simply omit the `defineTable` export:\n\n```typescript\n// definitions/features/rooms/room-amenities.ts\n\n// Junction table - no routes needed\nexport const roomAmenities = sqliteTable('room_amenities', {\n roomId: text('room_id').notNull(),\n amenityId: text('amenity_id').notNull(),\n});\n\n// No default export = no CRUD routes generated\n```\n\nThese internal tables still participate in the database schema and migrations — they just don't get API routes or security configuration.\n\n## Audit Fields\n\nQuickback automatically adds and manages these audit fields - you don't need to define them:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `createdAt` | timestamp | Set when record is created |\n| `createdBy` | text | User ID who created the record |\n| `modifiedAt` | timestamp | Updated on every change |\n| `modifiedBy` | text | User ID who last modified |\n| `deletedAt` | timestamp | Set on soft delete (optional) |\n| `deletedBy` | text | User ID who deleted (optional) |\n\nThe soft delete fields (`deletedAt`, `deletedBy`) are only added if your resource uses soft delete mode.\n\n### Disabling Audit Fields\n\nTo disable automatic audit fields for your project:\n\n```typescript\n// quickback.config.ts\nexport default defineConfig({\n // ...\n compiler: {\n features: {\n auditFields: false, // Disable for entire project\n }\n }\n});\n```\n\n### Protected System Fields\n\nThese fields are always protected and cannot be set by clients, even with `guards: false`:\n\n- `id` (when `generateId` is not `false`)\n- `createdAt`, `createdBy`\n- `modifiedAt`, `modifiedBy`\n- `deletedAt`, `deletedBy`\n\n## Ownership Fields\n\nFor the firewall to work, include the appropriate ownership columns:\n\n```typescript\n// For organization-scoped data (most common)\norganizationId: text('organization_id').notNull()\n\n// For user-owned data (personal data)\nownerId: text('owner_id').notNull()\n\n// For team-scoped data\nteamId: text('team_id').notNull()\n```\n\n## Relations (Optional)\n\nDrizzle supports defining relations for type-safe joins:\n\n```typescript\n\nexport const roomsRelations = relations(rooms, ({ one, many }) => ({\n organization: one(organizations, {\n fields: [rooms.organizationId],\n references: [organizations.id],\n }),\n bookings: many(bookings),\n}));\n```\n\n## Database Configuration\n\nConfigure database options in your Quickback config:\n\n```typescript\n// quickback.config.ts\nexport default defineConfig({\n name: 'my-app',\n providers: {\n database: defineDatabase('cloudflare-d1', {\n generateId: 'prefixed', // 'uuid' | 'cuid' | 'nanoid' | 'prefixed' | 'serial' | false\n namingConvention: 'snake_case', // 'camelCase' | 'snake_case'\n usePlurals: false, // Auth table names: 'users' vs 'user'\n }),\n },\n compiler: {\n features: {\n auditFields: true, // Auto-manage audit timestamps\n }\n }\n});\n```\n\n## Choosing Your Dialect\n\nUse the Drizzle dialect that matches your database provider:\n\n### SQLite (D1, Turso, better-sqlite3)\n\n```typescript\n\nexport const posts = sqliteTable('posts', {\n id: text('id').primaryKey(),\n title: text('title').notNull(),\n metadata: text('metadata', { mode: 'json' }), // JSON stored as text\n isPublished: integer('is_published', { mode: 'boolean' }).default(false),\n organizationId: text('organization_id').notNull(),\n});\n```\n\n### PostgreSQL (Supabase, Neon)\n\n```typescript\n\nexport const posts = pgTable('posts', {\n id: serial('id').primaryKey(),\n title: text('title').notNull(),\n metadata: jsonb('metadata'), // Native JSONB\n isPublished: boolean('is_published').default(false),\n organizationId: text('organization_id').notNull(),\n});\n```\n\n### Key Differences\n\n| Feature | SQLite | PostgreSQL |\n|---------|--------|------------|\n| Boolean | `integer({ mode: 'boolean' })` | `boolean()` |\n| JSON | `text({ mode: 'json' })` | `jsonb()` or `json()` |\n| Auto-increment | `integer().primaryKey()` | `serial()` |\n| UUID | `text()` | `uuid()` |\n\n## Next Steps\n\n- [Configure the firewall](/compiler/definitions/firewall) for data isolation\n- [Set up access control](/compiler/definitions/access) for CRUD operations\n- [Define guards](/compiler/definitions/guards) for field modification rules\n- [Add custom actions](/compiler/definitions/actions) for business logic"
|
|
174
|
+
"content": "Quickback uses [Drizzle ORM](https://orm.drizzle.team/) to define your database schema. With `defineTable`, you combine your schema definition and security configuration in a single file.\n\n## Defining Tables with defineTable\n\nEach table gets its own file with schema and config together. Use the Drizzle dialect that matches your target database:\n\n| Target Database | Import From | Table Function |\n|-----------------|-------------|----------------|\n| Cloudflare D1, Turso, SQLite | `drizzle-orm/sqlite-core` | `sqliteTable` |\n| Supabase, Neon, PostgreSQL | `drizzle-orm/pg-core` | `pgTable` |\n| PlanetScale, MySQL | `drizzle-orm/mysql-core` | `mysqlTable` |\n\n```typescript\n// definitions/features/rooms/rooms.ts\n// For D1/SQLite targets:\n\nexport const rooms = sqliteTable('rooms', {\n id: text('id').primaryKey(),\n name: text('name').notNull(),\n description: text('description'),\n capacity: integer('capacity').notNull().default(10),\n roomType: text('room_type').notNull(),\n isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),\n\n // Ownership - for firewall data isolation\n organizationId: text('organization_id').notNull(),\n});\n\nexport default defineTable(rooms, {\n firewall: { organization: {} },\n guards: {\n createable: [\"name\", \"description\", \"capacity\", \"roomType\"],\n updatable: [\"name\", \"description\", \"capacity\"],\n },\n crud: {\n list: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n get: { access: { roles: [\"owner\", \"admin\", \"member\"] } },\n create: { access: { roles: [\"owner\", \"admin\"] } },\n update: { access: { roles: [\"owner\", \"admin\"] } },\n delete: { access: { roles: [\"owner\", \"admin\"] } },\n },\n});\n\nexport type Room = typeof rooms.$inferSelect;\n```\n\n## Column Types\n\nDrizzle supports all standard SQL column types:\n\n| Type | Drizzle Function | Example |\n|------|------------------|---------|\n| String | `text()`, `varchar()` | `text('name')` |\n| Integer | `integer()`, `bigint()` | `integer('count')` |\n| Boolean | `boolean()` | `boolean('is_active')` |\n| Timestamp | `timestamp()` | `timestamp('created_at')` |\n| JSON | `json()`, `jsonb()` | `jsonb('metadata')` |\n| UUID | `uuid()` | `uuid('id')` |\n| Decimal | `decimal()`, `numeric()` | `decimal('price', { precision: 10, scale: 2 })` |\n\n## Column Modifiers\n\n```typescript\n// Required field\nname: text('name').notNull()\n\n// Default value\nisActive: boolean('is_active').default(true)\n\n// Primary key\nid: text('id').primaryKey()\n\n// Unique constraint\nemail: text('email').unique()\n\n// Default to current timestamp\ncreatedAt: timestamp('created_at').defaultNow()\n```\n\n## File Organization\n\nOrganize your tables by feature. Each feature directory contains table files:\n\n```\ndefinitions/\n└── features/\n ├── rooms/\n │ ├── rooms.ts # Main table + config\n │ ├── room-bookings.ts # Related table + config\n │ └── actions.ts # Custom actions\n ├── users/\n │ ├── users.ts # Table + config\n │ └── user-preferences.ts # Related table\n └── organizations/\n └── organizations.ts\n```\n\n**Key points:**\n- Tables with `export default defineTable(...)` get CRUD routes generated\n- Tables without a default export are internal (no routes, used for joins/relations)\n- Route paths are derived from filenames: `room-bookings.ts` → `/api/v1/room-bookings`\n\n### defineTable vs defineResource\n\nBoth functions are available:\n\n- **`defineTable`** - The standard function for defining tables with CRUD routes\n- **`defineResource`** - Alias for `defineTable`, useful when thinking in terms of REST resources\n\n```typescript\n// These are equivalent:\nexport default defineTable(rooms, { /* config */ });\nexport default defineResource(rooms, { /* config */ });\n```\n\n## 1 Resource = 1 Security Boundary\n\nEach `defineTable()` call defines a complete, self-contained security boundary. The security config you write — firewall, access, guards, and masking — is compiled into a single resource file that wraps all CRUD routes for that table.\n\nThis is a deliberate design choice. Mixing two resources with different security rules in one configuration would create ambiguity about which firewall, access, or masking rules apply to which table. By keeping it 1:1, there's never any question.\n\n| Scenario | What to do |\n|----------|------------|\n| Table needs its own API routes + security | Own file with `defineTable()` |\n| Table is internal/supporting (no direct API) | Extra `.ts` file in the parent feature directory, no `defineTable()` |\n\nA supporting table without `defineTable()` is useful when it's accessed internally — by action handlers, joins, or background jobs — but should never be directly exposed as its own API endpoint.\n\n## Internal Tables (No Routes)\n\nFor junction tables or internal data structures that shouldn't have API routes, simply omit the `defineTable` export:\n\n```typescript\n// definitions/features/rooms/room-amenities.ts\n\n// Junction table - no routes needed\nexport const roomAmenities = sqliteTable('room_amenities', {\n roomId: text('room_id').notNull(),\n amenityId: text('amenity_id').notNull(),\n});\n\n// No default export = no CRUD routes generated\n```\n\nThese internal tables still participate in the database schema and migrations — they just don't get API routes or security configuration.\n\nThe compiler automatically injects **audit fields** (`createdAt`, `modifiedAt`, `deletedAt`, etc.) and **`organizationId`** into child/junction tables when the parent feature is org-scoped. This ensures cascade soft deletes and scoped queries work correctly without manual column definitions.\n\n## Audit Fields\n\nQuickback automatically adds and manages these audit fields on **all tables** in a feature (including child/junction tables without `defineTable`) - you don't need to define them:\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `createdAt` | timestamp | Set when record is created |\n| `createdBy` | text | User ID who created the record |\n| `modifiedAt` | timestamp | Updated on every change |\n| `modifiedBy` | text | User ID who last modified |\n| `deletedAt` | timestamp | Set on soft delete (optional) |\n| `deletedBy` | text | User ID who deleted (optional) |\n\nThe soft delete fields (`deletedAt`, `deletedBy`) are only added if your resource uses soft delete mode.\n\n### Disabling Audit Fields\n\nTo disable automatic audit fields for your project:\n\n```typescript\n// quickback.config.ts\nexport default defineConfig({\n // ...\n compiler: {\n features: {\n auditFields: false, // Disable for entire project\n }\n }\n});\n```\n\n### Protected System Fields\n\nThese fields are always protected and cannot be set by clients, even with `guards: false`:\n\n- `id` (when `generateId` is not `false`)\n- `createdAt`, `createdBy`\n- `modifiedAt`, `modifiedBy`\n- `deletedAt`, `deletedBy`\n\n## Ownership Fields\n\nFor the firewall to work, include the appropriate ownership columns:\n\n```typescript\n// For organization-scoped data (most common)\norganizationId: text('organization_id').notNull()\n\n// For user-owned data (personal data)\nownerId: text('owner_id').notNull()\n\n// For team-scoped data\nteamId: text('team_id').notNull()\n```\n\n## Display Column\n\nWhen a table has foreign key columns (e.g., `roomTypeId` referencing `roomTypes`), Quickback can automatically resolve the human-readable label in GET and LIST responses. This eliminates the need for frontend lookup calls.\n\n### Auto-Detection\n\nQuickback auto-detects the display column by checking for common column names in this order:\n\n`name` → `title` → `label` → `headline` → `subject` → `code` → `displayName` → `fullName` → `description`\n\nIf your `roomTypes` table has a `name` column, it's automatically used as the display column. No config needed.\n\n### Explicit Override\n\nOverride the auto-detected column with `displayColumn`:\n\n```typescript\nexport default defineTable(accountCodes, {\n displayColumn: 'code', // Use 'code' instead of auto-detected 'name'\n firewall: { organization: {} },\n crud: {\n list: { access: { roles: [\"member\", \"admin\"] } },\n get: { access: { roles: [\"member\", \"admin\"] } },\n },\n});\n```\n\n### How Labels Appear in Responses\n\nFor any FK column ending in `Id`, the API adds a `_label` field with the resolved display value:\n\n```json\n{\n \"id\": \"rm_abc\",\n \"name\": \"Main Conference Room\",\n \"roomTypeId\": \"rt_xyz\",\n \"roomType_label\": \"Conference\",\n \"accountCodeId\": \"ac_123\",\n \"accountCode_label\": \"Revenue\"\n}\n```\n\nThe pattern is `{columnWithoutId}_label`. The frontend can find all labels with:\n\n```typescript\nObject.keys(record).filter(k => k.endsWith('_label'))\n```\n\nLabel resolution works within the same feature (tables in the same feature directory). System columns (`organizationId`, `createdBy`, `modifiedBy`) are never resolved.\n\nFor LIST endpoints, labels are batch-resolved efficiently — one query per FK column, not per record.\n\n## Relations (Optional)\n\nDrizzle supports defining relations for type-safe joins:\n\n```typescript\n\nexport const roomsRelations = relations(rooms, ({ one, many }) => ({\n organization: one(organizations, {\n fields: [rooms.organizationId],\n references: [organizations.id],\n }),\n bookings: many(bookings),\n}));\n```\n\n## Database Configuration\n\nConfigure database options in your Quickback config:\n\n```typescript\n// quickback.config.ts\nexport default defineConfig({\n name: 'my-app',\n providers: {\n database: defineDatabase('cloudflare-d1', {\n generateId: 'prefixed', // 'uuid' | 'cuid' | 'nanoid' | 'prefixed' | 'serial' | false\n namingConvention: 'snake_case', // 'camelCase' | 'snake_case'\n usePlurals: false, // Auth table names: 'users' vs 'user'\n }),\n },\n compiler: {\n features: {\n auditFields: true, // Auto-manage audit timestamps\n }\n }\n});\n```\n\n## Choosing Your Dialect\n\nUse the Drizzle dialect that matches your database provider:\n\n### SQLite (D1, Turso, better-sqlite3)\n\n```typescript\n\nexport const posts = sqliteTable('posts', {\n id: text('id').primaryKey(),\n title: text('title').notNull(),\n metadata: text('metadata', { mode: 'json' }), // JSON stored as text\n isPublished: integer('is_published', { mode: 'boolean' }).default(false),\n organizationId: text('organization_id').notNull(),\n});\n```\n\n### PostgreSQL (Supabase, Neon)\n\n```typescript\n\nexport const posts = pgTable('posts', {\n id: serial('id').primaryKey(),\n title: text('title').notNull(),\n metadata: jsonb('metadata'), // Native JSONB\n isPublished: boolean('is_published').default(false),\n organizationId: text('organization_id').notNull(),\n});\n```\n\n### Key Differences\n\n| Feature | SQLite | PostgreSQL |\n|---------|--------|------------|\n| Boolean | `integer({ mode: 'boolean' })` | `boolean()` |\n| JSON | `text({ mode: 'json' })` | `jsonb()` or `json()` |\n| Auto-increment | `integer().primaryKey()` | `serial()` |\n| UUID | `text()` | `uuid()` |\n\n## Next Steps\n\n- [Configure the firewall](/compiler/definitions/firewall) for data isolation\n- [Set up access control](/compiler/definitions/access) for CRUD operations\n- [Define guards](/compiler/definitions/guards) for field modification rules\n- [Add custom actions](/compiler/definitions/actions) for business logic"
|
|
139
175
|
},
|
|
140
176
|
"compiler/definitions/validation": {
|
|
141
177
|
"title": "Validation",
|
|
@@ -143,7 +179,7 @@ export const DOCS = {
|
|
|
143
179
|
},
|
|
144
180
|
"compiler/definitions/views": {
|
|
145
181
|
"title": "Views - Column Level Security",
|
|
146
|
-
"content": "Views implement **Column Level Security (CLS)** - controlling which columns users can access. This complements **Row Level Security (RLS)** provided by the [Firewall](/compiler/definitions/firewall), which controls which rows users can access.\n\n| Security Layer | Controls | Quickback Feature |\n|----------------|----------|-------------------|\n| Row Level Security | Which records | Firewall |\n| Column Level Security | Which fields | Views |\n\nViews provide named field projections with role-based access control. Use views to return different sets of columns to different users without duplicating CRUD endpoints.\n\n## When to Use Views\n\n| Concept | Purpose | Example |\n|---------|---------|---------|\n| CRUD list | Full records | Returns all columns |\n| Masking | Hide values | SSN
|
|
182
|
+
"content": "Views implement **Column Level Security (CLS)** - controlling which columns users can access. This complements **Row Level Security (RLS)** provided by the [Firewall](/compiler/definitions/firewall), which controls which rows users can access.\n\n| Security Layer | Controls | Quickback Feature |\n|----------------|----------|-------------------|\n| Row Level Security | Which records | Firewall |\n| Column Level Security | Which fields | Views |\n\nViews provide named field projections with role-based access control. Use views to return different sets of columns to different users without duplicating CRUD endpoints.\n\n## When to Use Views\n\n| Concept | Purpose | Example |\n|---------|---------|---------|\n| CRUD list | Full records | Returns all columns |\n| Masking | Hide values | SSN `*****6789` |\n| Views | Exclude columns | Only `id`, `name`, `status` |\n\n- **CRUD list** returns all fields to authorized users\n- **Masking** transforms sensitive values but still includes the column\n- **Views** completely exclude columns from the response\n\n## Basic Usage\n\n```typescript\n// features/customers/customers.ts\n\nexport const customers = sqliteTable('customers', {\n id: text('id').primaryKey(),\n name: text('name').notNull(),\n email: text('email').notNull(),\n phone: text('phone'),\n ssn: text('ssn'),\n internalNotes: text('internal_notes'),\n organizationId: text('organization_id').notNull(),\n});\n\nexport default defineTable(customers, {\n masking: {\n ssn: { type: 'ssn', show: { roles: ['admin'] } },\n },\n\n crud: {\n list: { access: { roles: ['owner', 'admin', 'member'] } },\n get: { access: { roles: ['owner', 'admin', 'member'] } },\n },\n\n // Views: Named field projections\n views: {\n summary: {\n fields: ['id', 'name', 'email'],\n access: { roles: ['owner', 'admin', 'member'] },\n },\n full: {\n fields: ['id', 'name', 'email', 'phone', 'ssn', 'internalNotes'],\n access: { roles: ['admin'] },\n },\n report: {\n fields: ['id', 'name', 'email', 'createdAt'],\n access: { roles: ['finance', 'admin'] },\n },\n },\n});\n```\n\n## Generated Endpoints\n\nViews generate GET endpoints at `/{resource}/views/{viewName}`:\n\n```\nGET /api/v1/customers # CRUD list - all fields\nGET /api/v1/customers/views/summary # View - only summary fields\nGET /api/v1/customers/views/full # View - all fields (admin only)\nGET /api/v1/customers/views/report # View - report fields\n```\n\n## Query Parameters\n\nViews support the same query parameters as the list endpoint:\n\n### Pagination\n\n| Parameter | Description | Default | Max |\n|-----------|-------------|---------|-----|\n| `limit` | Number of records to return | 50 | 100 |\n| `offset` | Number of records to skip | 0 | - |\n\n```bash\n# Get first 10 records\nGET /api/v1/customers/views/summary?limit=10\n\n# Get records 11-20\nGET /api/v1/customers/views/summary?limit=10&offset=10\n```\n\n### Filtering\n\n```bash\n# Filter by exact value\nGET /api/v1/customers/views/summary?status=active\n\n# Filter with operators\nGET /api/v1/customers/views/summary?createdAt.gt=2024-01-01\n\n# Multiple filters (AND logic)\nGET /api/v1/customers/views/summary?status=active&email.like=yourdomain.com\n```\n\n### Sorting\n\n| Parameter | Description | Default |\n|-----------|-------------|---------|\n| `sort` | Field to sort by | `createdAt` |\n| `order` | Sort direction (`asc` or `desc`) | `desc` |\n\n```bash\nGET /api/v1/customers/views/summary?sort=name&order=asc\n```\n\n## Security\n\nAll four security pillars apply to views:\n\n| Pillar | Behavior |\n|--------|----------|\n| **Firewall** | WHERE clause applied (same as list) |\n| **Access** | Per-view access control |\n| **Guards** | N/A (read-only) |\n| **Masking** | Applied to returned fields |\n\n### Firewall\n\nViews automatically apply the same firewall conditions as the list endpoint. Users only see records within their organization scope.\n\n### Access Control\n\nEach view has its own access configuration:\n\n```typescript\nviews: {\n // Public view - available to all members\n summary: {\n fields: ['id', 'name', 'status'],\n access: { roles: ['owner', 'admin', 'member'] },\n },\n // Restricted view - admin only\n full: {\n fields: ['id', 'name', 'status', 'ssn', 'internalNotes'],\n access: { roles: ['admin'] },\n },\n}\n```\n\n### Masking\n\nMasking rules are applied to the returned fields. If a view includes a masked field like `ssn`, the masking rules still apply:\n\n```typescript\nmasking: {\n ssn: { type: 'ssn', show: { roles: ['admin'] } },\n},\n\nviews: {\n // Even if a member accesses the 'full' view, ssn will be masked\n // because masking rules take precedence\n full: {\n fields: ['id', 'name', 'ssn'],\n access: { roles: ['owner', 'admin', 'member'] },\n },\n}\n```\n\n### How Views and Masking Work Together\n\nViews and masking are orthogonal concerns:\n- **Views** control field selection (which columns appear)\n- **Masking** controls field transformation (how values appear based on role)\n\nExample configuration:\n\n```typescript\nmasking: {\n ssn: { type: 'ssn', show: { roles: ['admin'] } },\n},\n\nviews: {\n summary: {\n fields: ['id', 'name'], // ssn NOT included\n access: { roles: ['owner', 'admin', 'member'] },\n },\n full: {\n fields: ['id', 'name', 'ssn'], // ssn included\n access: { roles: ['owner', 'admin', 'member'] },\n },\n}\n```\n\n| Endpoint | Role | `ssn` in response? | `ssn` value |\n|----------|------|-------------------|-------------|\n| `/views/summary` | member | No | N/A |\n| `/views/summary` | admin | No | N/A |\n| `/views/full` | member | Yes | `*****6789` |\n| `/views/full` | admin | Yes | `123-45-6789` |\n\n## Response Format\n\nView responses include metadata about the view:\n\n```json\n{\n \"data\": [\n { \"id\": \"cust_123\", \"name\": \"John Doe\", \"email\": \"john@yourdomain.com\" },\n { \"id\": \"cust_456\", \"name\": \"Jane Smith\", \"email\": \"jane@yourdomain.com\" }\n ],\n \"view\": \"summary\",\n \"fields\": [\"id\", \"name\", \"email\"],\n \"pagination\": {\n \"limit\": 50,\n \"offset\": 0,\n \"count\": 2\n }\n}\n```\n\n## Complete Example\n\n```typescript\n// features/employees/employees.ts\nexport default defineTable(employees, {\n guards: {\n createable: ['name', 'email', 'phone', 'department'],\n updatable: ['name', 'email', 'phone'],\n },\n\n masking: {\n ssn: { type: 'ssn', show: { roles: ['hr', 'admin'] } },\n salary: { type: 'redact', show: { roles: ['hr', 'admin'] } },\n personalEmail: { type: 'email', show: { or: 'owner' } },\n },\n\n crud: {\n list: { access: { roles: ['owner', 'admin', 'member'] } },\n get: { access: { roles: ['owner', 'admin', 'member'] } },\n create: { access: { roles: ['owner', 'admin'] } },\n update: { access: { roles: ['owner', 'admin'] } },\n delete: { access: { roles: ['owner', 'admin'] } },\n },\n\n views: {\n // Directory view - public employee info\n directory: {\n fields: ['id', 'name', 'email', 'department'],\n access: { roles: ['owner', 'admin', 'member'] },\n },\n // HR view - includes sensitive info\n hr: {\n fields: ['id', 'name', 'email', 'phone', 'ssn', 'salary', 'department', 'startDate'],\n access: { roles: ['owner', 'admin'] },\n },\n // Payroll export\n payroll: {\n fields: ['id', 'name', 'ssn', 'salary', 'bankAccount'],\n access: { roles: ['owner', 'admin'] },\n },\n },\n});\n```"
|
|
147
183
|
},
|
|
148
184
|
"compiler/getting-started/claude-code": {
|
|
149
185
|
"title": "Claude Code Skill",
|
|
@@ -151,7 +187,7 @@ export const DOCS = {
|
|
|
151
187
|
},
|
|
152
188
|
"compiler/getting-started/full-example": {
|
|
153
189
|
"title": "Complete Example",
|
|
154
|
-
"content": "This example shows a complete resource definition using all five security layers, and the production code Quickback generates from it.\n\n## What You Define\n\nA `customers` resource for a multi-tenant SaaS application with:\n- Organization-level data isolation\n- Role-based CRUD permissions\n- Field-level write protection\n- PII masking for sensitive fields\n- Column-level views for different access levels\n\n### Schema\n\n```typescript title=\"definitions/features/customers/schema.ts\"\n\nexport const customers = sqliteTable('customers', {\n id: text('id').primaryKey(),\n organizationId: text('organization_id').notNull(),\n name: text('name').notNull(),\n email: text('email').notNull(),\n phone: text('phone'),\n ssn: text('ssn'),\n});\n\n// Note: createdAt, createdBy, modifiedAt, modifiedBy are auto-injected\n```\n\n### Resource Configuration\n\n```typescript title=\"definitions/features/customers/resource.ts\"\n\nexport default defineResource(customers, {\n // FIREWALL: Data isolation\n firewall: {\n organization: {}\n },\n\n // ACCESS: Role-based permissions\n crud: {\n list: { access: { roles: ['owner', 'admin', 'member', 'support'] } },\n get: { access: { roles: ['owner', 'admin', 'member', 'support'] } },\n create: { access: { roles: ['admin'] } },\n update: { access: { roles: ['admin', 'support'] } },\n delete: { access: { roles: ['admin'] }, mode: 'hard' },\n },\n\n // GUARDS: Field-level protection\n guards: {\n createable: ['name', 'email', 'phone', 'ssn'],\n updatable: ['name', 'email', 'phone'],\n immutable: ['ssn'], // Can set once, never change\n },\n\n // MASKING: PII redaction\n masking: {\n ssn: { type: 'ssn', show: { roles: ['admin'] } },\n phone: { type: 'phone', show: { roles: ['admin', 'support'] } },\n email: { type: 'email', show: { roles: ['admin'] } },\n },\n\n // VIEWS: Column-level projections\n views: {\n summary: {\n fields: ['id', 'name', 'email'],\n access: { roles: ['owner', 'admin', 'member', 'support'] },\n },\n full: {\n fields: ['id', 'name', 'email', 'phone', 'ssn'],\n access: { roles: ['owner', 'admin'] },\n },\n },\n});\n```\n\n---\n\n## What Quickback Generates\n\nRun `quickback compile` and Quickback generates production-ready code.\n\n### Security Helpers\n\n```typescript title=\"src/features/customers/customers.resource.ts\"\n\n/**\n * Firewall conditions for customers\n * Pattern: Organization only\n */\nexport function buildFirewallConditions(ctx: AppContext) {\n const conditions = [];\n\n // Organization isolation\n conditions.push(eq(customers.organizationId, ctx.activeOrgId!));\n\n return and(...conditions);\n}\n\n/**\n * Guards configuration for field modification rules\n */\nexport const GUARDS_CONFIG = {\n createable: new Set(['name', 'email', 'phone', 'ssn']),\n updatable: new Set(['name', 'email', 'phone']),\n immutable: new Set(['ssn']),\n protected: {},\n systemManaged: new Set([\n 'createdAt', 'createdBy',\n 'modifiedAt', 'modifiedBy',\n 'deletedAt', 'deletedBy'\n ]),\n};\n\n/**\n * Validates field input for CREATE operations.\n */\nexport function validateCreate(input: Record<string, any>) {\n const systemManaged: string[] = [];\n const protectedFields: Array<{ field: string; actions: string[] }> = [];\n const notCreateable: string[] = [];\n\n for (const field of Object.keys(input)) {\n if (GUARDS_CONFIG.systemManaged.has(field)) {\n systemManaged.push(field);\n continue;\n }\n if (field in GUARDS_CONFIG.protected) {\n const actions = [...GUARDS_CONFIG.protected[field]];\n protectedFields.push({ field, actions });\n continue;\n }\n if (!GUARDS_CONFIG.createable.has(field) && !GUARDS_CONFIG.immutable.has(field)) {\n notCreateable.push(field);\n }\n }\n\n const valid = systemManaged.length === 0 &&\n protectedFields.length === 0 &&\n notCreateable.length === 0;\n\n return { valid, systemManaged, protected: protectedFields, notCreateable };\n}\n\n/**\n * Validates field input for UPDATE operations.\n */\nexport function validateUpdate(input: Record<string, any>) {\n const systemManaged: string[] = [];\n const immutable: string[] = [];\n const protectedFields: Array<{ field: string; actions: string[] }> = [];\n const notUpdatable: string[] = [];\n\n for (const field of Object.keys(input)) {\n if (GUARDS_CONFIG.systemManaged.has(field)) {\n systemManaged.push(field);\n continue;\n }\n if (GUARDS_CONFIG.immutable.has(field)) {\n immutable.push(field);\n continue;\n }\n if (field in GUARDS_CONFIG.protected) {\n const actions = [...GUARDS_CONFIG.protected[field]];\n protectedFields.push({ field, actions });\n continue;\n }\n if (!GUARDS_CONFIG.updatable.has(field)) {\n notUpdatable.push(field);\n }\n }\n\n const valid = systemManaged.length === 0 &&\n immutable.length === 0 &&\n protectedFields.length === 0 &&\n notUpdatable.length === 0;\n\n return { valid, systemManaged, immutable, protected: protectedFields, notUpdatable };\n}\n\n/**\n * Masks sensitive fields based on user role\n */\nexport function maskCustomer<T extends Record<string, any>>(\n record: T,\n ctx: AppContext\n): T {\n const masked: any = { ...record };\n\n // email: show to admin only\n if (!ctx.roles?.includes('admin')) {\n if (masked['email'] != null) {\n masked['email'] = masks.email(masked['email']);\n }\n }\n\n // phone: show to admin, support\n if (!ctx.roles?.some(r => ['admin', 'support'].includes(r))) {\n if (masked['phone'] != null) {\n masked['phone'] = masks.phone(masked['phone']);\n }\n }\n\n // ssn: show to admin only\n if (!ctx.roles?.includes('admin')) {\n if (masked['ssn'] != null) {\n masked['ssn'] = masks.ssn(masked['ssn']);\n }\n }\n\n return masked as T;\n}\n\nexport function maskCustomers<T extends Record<string, any>>(\n records: T[],\n ctx: AppContext\n): T[] {\n return records.map(r => maskCustomer(r, ctx));\n}\n\n// CRUD Access configuration\nexport const CRUD_ACCESS = {\n list: { access: { roles: ['owner', 'admin', 'member', 'support'] } },\n get: { access: { roles: ['owner', 'admin', 'member', 'support'] } },\n create: { access: { roles: ['admin'] } },\n update: { access: { roles: ['admin', 'support'] } },\n delete: { access: { roles: ['admin'] }, mode: 'hard' },\n};\n\n// Views configuration\nexport const VIEWS_CONFIG = {\n summary: {\n fields: ['id', 'name', 'email'],\n access: { roles: ['owner', 'admin', 'member', 'support'] },\n },\n full: {\n fields: ['id', 'name', 'email', 'phone', 'ssn'],\n access: { roles: ['owner', 'admin'] },\n },\n};\n```\n\n### API Routes\n\nThe generated routes wire everything together:\n\n```typescript title=\"src/features/customers/customers.routes.ts\"\n\n buildFirewallConditions,\n validateCreate,\n validateUpdate,\n maskCustomer,\n maskCustomers,\n CRUD_ACCESS\n} from './customers.resource';\n\nconst app = new Hono();\n\n// GET /customers - List with firewall + masking\napp.get('/', async (c) => {\n const ctx = c.get('ctx');\n const db = c.get('db');\n\n // Access check\n if (!await evaluateAccess(CRUD_ACCESS.list.access, ctx)) {\n return c.json(AccessErrors.roleRequired(\n CRUD_ACCESS.list.access.roles,\n ctx.roles\n ), 403);\n }\n\n // Query with firewall conditions\n const results = await db.select().from(customers)\n .where(buildFirewallConditions(ctx));\n\n // Apply masking before returning\n return c.json({\n data: maskCustomers(results, ctx),\n });\n});\n\n// POST /customers - Create with guards\napp.post('/', async (c) => {\n const ctx = c.get('ctx');\n const db = c.get('db');\n\n // Access check\n if (!await evaluateAccess(CRUD_ACCESS.create.access, ctx)) {\n return c.json(AccessErrors.roleRequired(\n CRUD_ACCESS.create.access.roles,\n ctx.roles\n ), 403);\n }\n\n const body = await c.req.json();\n\n // Guards validation\n const validation = validateCreate(body);\n if (!validation.valid) {\n if (validation.systemManaged.length > 0) {\n return c.json(GuardErrors.systemManaged(validation.systemManaged), 400);\n }\n if (validation.notCreateable.length > 0) {\n return c.json(GuardErrors.fieldNotCreateable(validation.notCreateable), 400);\n }\n }\n\n // Apply ownership and audit fields\n const data = {\n id: 'cst_' + crypto.randomUUID().replace(/-/g, ''),\n ...body,\n organizationId: ctx.activeOrgId,\n createdAt: new Date().toISOString(),\n createdBy: ctx.userId,\n modifiedAt: new Date().toISOString(),\n modifiedBy: ctx.userId,\n };\n\n const result = await db.insert(customers).values(data).returning();\n return c.json(maskCustomer(result[0], ctx), 201);\n});\n\n// PATCH /customers/:id - Update with guards\napp.patch('/:id', async (c) => {\n const ctx = c.get('ctx');\n const db = c.get('db');\n const id = c.req.param('id');\n\n // Fetch with firewall\n const [record] = await db.select().from(customers)\n .where(and(buildFirewallConditions(ctx), eq(customers.id, id)));\n\n if (!record) {\n return c.json({ error: 'Not found', code: 'NOT_FOUND' }, 404);\n }\n\n // Access check\n if (!await evaluateAccess(CRUD_ACCESS.update.access, ctx)) {\n return c.json(AccessErrors.roleRequired(\n CRUD_ACCESS.update.access.roles,\n ctx.roles\n ), 403);\n }\n\n const body = await c.req.json();\n\n // Guards validation\n const validation = validateUpdate(body);\n if (!validation.valid) {\n if (validation.immutable.length > 0) {\n return c.json(GuardErrors.fieldImmutable(validation.immutable), 400);\n }\n if (validation.notUpdatable.length > 0) {\n return c.json(GuardErrors.fieldNotUpdatable(validation.notUpdatable), 400);\n }\n }\n\n // Apply audit fields\n const data = {\n ...body,\n modifiedAt: new Date().toISOString(),\n modifiedBy: ctx.userId,\n };\n\n const result = await db.update(customers).set(data)\n .where(eq(customers.id, id)).returning();\n\n return c.json(maskCustomer(result[0], ctx));\n});\n\nexport default app;\n```\n\n---\n\n## Runtime Behavior\n\n### Masking by Role\n\n| Role | `email` | `phone` | `ssn` |\n|------|---------|---------|-------|\n| **admin** | `john@example.com` | `555-123-4567` | `123-45-6789` |\n| **support** | `j***@e***.com` | `555-123-4567` | `***-**-6789` |\n| **member** | `j***@e***.com` | `***-***-4567` | `***-**-6789` |\n\n### Views + Masking Interaction\n\nViews control **which fields are returned**. Masking controls **what values are shown**.\n\n| Endpoint | Role | `ssn` in response? | `ssn` value |\n|----------|------|--------------------|-------------|\n| `/customers/views/summary` | member | No | N/A |\n| `/customers/views/summary` | admin | No | N/A |\n| `/customers/views/full` | member | Yes | `***-**-6789` |\n| `/customers/views/full` | admin | Yes | `123-45-6789` |\n\n### Guards Enforcement\n\n```bash\n# ✅ Allowed: Create with createable fields\nPOST /customers\n{ \"name\": \"Jane\", \"email\": \"jane@example.com\", \"ssn\": \"987-65-4321\" }\n\n# ❌ Rejected: Update immutable field\nPATCH /customers/cst_123\n{ \"ssn\": \"000-00-0000\" }\n# → 400: \"Field 'ssn' is immutable and cannot be modified\"\n\n# ❌ Rejected: Set system-managed field\nPOST /customers\n{ \"name\": \"Jane\", \"createdBy\": \"hacker\" }\n# → 400: \"System-managed fields cannot be set: createdBy\"\n```\n\n---\n\n## Generated API Endpoints\n\n| Method | Endpoint | Access |\n|--------|----------|--------|\n| `GET` | `/customers` | owner, admin, member, support |\n| `GET` | `/customers/:id` | owner, admin, member, support |\n| `POST` | `/customers` | admin |\n| `PATCH` | `/customers/:id` | admin, support |\n| `DELETE` | `/customers/:id` | admin |\n| `GET` | `/customers/views/summary` | owner, admin, member, support |\n| `GET` | `/customers/views/full` | admin |\n\n---\n\n## Key Takeaways\n\n1. **~50 lines of configuration** generates **~500+ lines of production code**\n2. **Security is declarative** - you describe *what* you want, not *how*\n3. **All layers work together** - Firewall → Access → Guards → Masking\n4. **Code is readable and auditable** - no magic, just TypeScript\n5. **Type-safe from definition to API** - Drizzle schema drives everything"
|
|
190
|
+
"content": "This example shows a complete resource definition using all five security layers, and the production code Quickback generates from it.\n\n## What You Define\n\nA `customers` resource for a multi-tenant SaaS application with:\n- Organization-level data isolation\n- Role-based CRUD permissions\n- Field-level write protection\n- PII masking for sensitive fields\n- Column-level views for different access levels\n\n### Schema\n\n```typescript title=\"definitions/features/customers/schema.ts\"\n\nexport const customers = sqliteTable('customers', {\n id: text('id').primaryKey(),\n organizationId: text('organization_id').notNull(),\n name: text('name').notNull(),\n email: text('email').notNull(),\n phone: text('phone'),\n ssn: text('ssn'),\n});\n\n// Note: createdAt, createdBy, modifiedAt, modifiedBy are auto-injected\n```\n\n### Resource Configuration\n\n```typescript title=\"definitions/features/customers/resource.ts\"\n\nexport default defineResource(customers, {\n // FIREWALL: Data isolation\n firewall: {\n organization: {}\n },\n\n // ACCESS: Role-based permissions\n crud: {\n list: { access: { roles: ['owner', 'admin', 'member', 'support'] } },\n get: { access: { roles: ['owner', 'admin', 'member', 'support'] } },\n create: { access: { roles: ['admin'] } },\n update: { access: { roles: ['admin', 'support'] } },\n delete: { access: { roles: ['admin'] }, mode: 'hard' },\n },\n\n // GUARDS: Field-level protection\n guards: {\n createable: ['name', 'email', 'phone', 'ssn'],\n updatable: ['name', 'email', 'phone'],\n immutable: ['ssn'], // Can set once, never change\n },\n\n // MASKING: PII redaction\n masking: {\n ssn: { type: 'ssn', show: { roles: ['admin'] } },\n phone: { type: 'phone', show: { roles: ['admin', 'support'] } },\n email: { type: 'email', show: { roles: ['admin'] } },\n },\n\n // VIEWS: Column-level projections\n views: {\n summary: {\n fields: ['id', 'name', 'email'],\n access: { roles: ['owner', 'admin', 'member', 'support'] },\n },\n full: {\n fields: ['id', 'name', 'email', 'phone', 'ssn'],\n access: { roles: ['owner', 'admin'] },\n },\n },\n});\n```\n\n---\n\n## What Quickback Generates\n\nRun `quickback compile` and Quickback generates production-ready code.\n\n### Security Helpers\n\n```typescript title=\"src/features/customers/customers.resource.ts\"\n\n/**\n * Firewall conditions for customers\n * Pattern: Organization only\n */\nexport function buildFirewallConditions(ctx: AppContext) {\n const conditions = [];\n\n // Organization isolation\n conditions.push(eq(customers.organizationId, ctx.activeOrgId!));\n\n return and(...conditions);\n}\n\n/**\n * Guards configuration for field modification rules\n */\nexport const GUARDS_CONFIG = {\n createable: new Set(['name', 'email', 'phone', 'ssn']),\n updatable: new Set(['name', 'email', 'phone']),\n immutable: new Set(['ssn']),\n protected: {},\n systemManaged: new Set([\n 'createdAt', 'createdBy',\n 'modifiedAt', 'modifiedBy',\n 'deletedAt', 'deletedBy'\n ]),\n};\n\n/**\n * Validates field input for CREATE operations.\n */\nexport function validateCreate(input: Record<string, any>) {\n const systemManaged: string[] = [];\n const protectedFields: Array<{ field: string; actions: string[] }> = [];\n const notCreateable: string[] = [];\n\n for (const field of Object.keys(input)) {\n if (GUARDS_CONFIG.systemManaged.has(field)) {\n systemManaged.push(field);\n continue;\n }\n if (field in GUARDS_CONFIG.protected) {\n const actions = [...GUARDS_CONFIG.protected[field]];\n protectedFields.push({ field, actions });\n continue;\n }\n if (!GUARDS_CONFIG.createable.has(field) && !GUARDS_CONFIG.immutable.has(field)) {\n notCreateable.push(field);\n }\n }\n\n const valid = systemManaged.length === 0 &&\n protectedFields.length === 0 &&\n notCreateable.length === 0;\n\n return { valid, systemManaged, protected: protectedFields, notCreateable };\n}\n\n/**\n * Validates field input for UPDATE operations.\n */\nexport function validateUpdate(input: Record<string, any>) {\n const systemManaged: string[] = [];\n const immutable: string[] = [];\n const protectedFields: Array<{ field: string; actions: string[] }> = [];\n const notUpdatable: string[] = [];\n\n for (const field of Object.keys(input)) {\n if (GUARDS_CONFIG.systemManaged.has(field)) {\n systemManaged.push(field);\n continue;\n }\n if (GUARDS_CONFIG.immutable.has(field)) {\n immutable.push(field);\n continue;\n }\n if (field in GUARDS_CONFIG.protected) {\n const actions = [...GUARDS_CONFIG.protected[field]];\n protectedFields.push({ field, actions });\n continue;\n }\n if (!GUARDS_CONFIG.updatable.has(field)) {\n notUpdatable.push(field);\n }\n }\n\n const valid = systemManaged.length === 0 &&\n immutable.length === 0 &&\n protectedFields.length === 0 &&\n notUpdatable.length === 0;\n\n return { valid, systemManaged, immutable, protected: protectedFields, notUpdatable };\n}\n\n/**\n * Masks sensitive fields based on user role\n */\nexport function maskCustomer<T extends Record<string, any>>(\n record: T,\n ctx: AppContext\n): T {\n const masked: any = { ...record };\n\n // email: show to admin only\n if (!ctx.roles?.includes('admin')) {\n if (masked['email'] != null) {\n masked['email'] = masks.email(masked['email']);\n }\n }\n\n // phone: show to admin, support\n if (!ctx.roles?.some(r => ['admin', 'support'].includes(r))) {\n if (masked['phone'] != null) {\n masked['phone'] = masks.phone(masked['phone']);\n }\n }\n\n // ssn: show to admin only\n if (!ctx.roles?.includes('admin')) {\n if (masked['ssn'] != null) {\n masked['ssn'] = masks.ssn(masked['ssn']);\n }\n }\n\n return masked as T;\n}\n\nexport function maskCustomers<T extends Record<string, any>>(\n records: T[],\n ctx: AppContext\n): T[] {\n return records.map(r => maskCustomer(r, ctx));\n}\n\n// CRUD Access configuration\nexport const CRUD_ACCESS = {\n list: { access: { roles: ['owner', 'admin', 'member', 'support'] } },\n get: { access: { roles: ['owner', 'admin', 'member', 'support'] } },\n create: { access: { roles: ['admin'] } },\n update: { access: { roles: ['admin', 'support'] } },\n delete: { access: { roles: ['admin'] }, mode: 'hard' },\n};\n\n// Views configuration\nexport const VIEWS_CONFIG = {\n summary: {\n fields: ['id', 'name', 'email'],\n access: { roles: ['owner', 'admin', 'member', 'support'] },\n },\n full: {\n fields: ['id', 'name', 'email', 'phone', 'ssn'],\n access: { roles: ['owner', 'admin'] },\n },\n};\n```\n\n### API Routes\n\nThe generated routes wire everything together:\n\n```typescript title=\"src/features/customers/customers.routes.ts\"\n\n buildFirewallConditions,\n validateCreate,\n validateUpdate,\n maskCustomer,\n maskCustomers,\n CRUD_ACCESS\n} from './customers.resource';\n\nconst app = new Hono();\n\n// GET /customers - List with firewall + masking\napp.get('/', async (c) => {\n const ctx = c.get('ctx');\n const db = c.get('db');\n\n // Access check\n if (!await evaluateAccess(CRUD_ACCESS.list.access, ctx)) {\n return c.json(AccessErrors.roleRequired(\n CRUD_ACCESS.list.access.roles,\n ctx.roles\n ), 403);\n }\n\n // Query with firewall conditions\n const results = await db.select().from(customers)\n .where(buildFirewallConditions(ctx));\n\n // Apply masking before returning\n return c.json({\n data: maskCustomers(results, ctx),\n });\n});\n\n// POST /customers - Create with guards\napp.post('/', async (c) => {\n const ctx = c.get('ctx');\n const db = c.get('db');\n\n // Access check\n if (!await evaluateAccess(CRUD_ACCESS.create.access, ctx)) {\n return c.json(AccessErrors.roleRequired(\n CRUD_ACCESS.create.access.roles,\n ctx.roles\n ), 403);\n }\n\n const body = await c.req.json();\n\n // Guards validation\n const validation = validateCreate(body);\n if (!validation.valid) {\n if (validation.systemManaged.length > 0) {\n return c.json(GuardErrors.systemManaged(validation.systemManaged), 400);\n }\n if (validation.notCreateable.length > 0) {\n return c.json(GuardErrors.fieldNotCreateable(validation.notCreateable), 400);\n }\n }\n\n // Apply ownership and audit fields\n const data = {\n id: 'cst_' + crypto.randomUUID().replace(/-/g, ''),\n ...body,\n organizationId: ctx.activeOrgId,\n createdAt: new Date().toISOString(),\n createdBy: ctx.userId,\n modifiedAt: new Date().toISOString(),\n modifiedBy: ctx.userId,\n };\n\n const result = await db.insert(customers).values(data).returning();\n return c.json(maskCustomer(result[0], ctx), 201);\n});\n\n// PATCH /customers/:id - Update with guards\napp.patch('/:id', async (c) => {\n const ctx = c.get('ctx');\n const db = c.get('db');\n const id = c.req.param('id');\n\n // Fetch with firewall\n const [record] = await db.select().from(customers)\n .where(and(buildFirewallConditions(ctx), eq(customers.id, id)));\n\n if (!record) {\n return c.json({ error: 'Not found', code: 'NOT_FOUND' }, 404);\n }\n\n // Access check\n if (!await evaluateAccess(CRUD_ACCESS.update.access, ctx)) {\n return c.json(AccessErrors.roleRequired(\n CRUD_ACCESS.update.access.roles,\n ctx.roles\n ), 403);\n }\n\n const body = await c.req.json();\n\n // Guards validation\n const validation = validateUpdate(body);\n if (!validation.valid) {\n if (validation.immutable.length > 0) {\n return c.json(GuardErrors.fieldImmutable(validation.immutable), 400);\n }\n if (validation.notUpdatable.length > 0) {\n return c.json(GuardErrors.fieldNotUpdatable(validation.notUpdatable), 400);\n }\n }\n\n // Apply audit fields\n const data = {\n ...body,\n modifiedAt: new Date().toISOString(),\n modifiedBy: ctx.userId,\n };\n\n const result = await db.update(customers).set(data)\n .where(eq(customers.id, id)).returning();\n\n return c.json(maskCustomer(result[0], ctx));\n});\n\nexport default app;\n```\n\n---\n\n## Runtime Behavior\n\n### Masking by Role\n\n| Role | `email` | `phone` | `ssn` |\n|------|---------|---------|-------|\n| **admin** | `john@example.com` | `555-123-4567` | `123-45-6789` |\n| **support** | `j***@e******.com` | `555-123-4567` | `*****6789` |\n| **member** | `j***@e******.com` | `******4567` | `*****6789` |\n\n### Views + Masking Interaction\n\nViews control **which fields are returned**. Masking controls **what values are shown**.\n\n| Endpoint | Role | `ssn` in response? | `ssn` value |\n|----------|------|--------------------|-------------|\n| `/customers/views/summary` | member | No | N/A |\n| `/customers/views/summary` | admin | No | N/A |\n| `/customers/views/full` | member | Yes | `*****6789` |\n| `/customers/views/full` | admin | Yes | `123-45-6789` |\n\n### Guards Enforcement\n\n```bash\n# ✅ Allowed: Create with createable fields\nPOST /customers\n{ \"name\": \"Jane\", \"email\": \"jane@example.com\", \"ssn\": \"987-65-4321\" }\n\n# ❌ Rejected: Update immutable field\nPATCH /customers/cst_123\n{ \"ssn\": \"000-00-0000\" }\n# → 400: \"Field 'ssn' is immutable and cannot be modified\"\n\n# ❌ Rejected: Set system-managed field\nPOST /customers\n{ \"name\": \"Jane\", \"createdBy\": \"hacker\" }\n# → 400: \"System-managed fields cannot be set: createdBy\"\n```\n\n---\n\n## Generated API Endpoints\n\n| Method | Endpoint | Access |\n|--------|----------|--------|\n| `GET` | `/customers` | owner, admin, member, support |\n| `GET` | `/customers/:id` | owner, admin, member, support |\n| `POST` | `/customers` | admin |\n| `PATCH` | `/customers/:id` | admin, support |\n| `DELETE` | `/customers/:id` | admin |\n| `GET` | `/customers/views/summary` | owner, admin, member, support |\n| `GET` | `/customers/views/full` | admin |\n\n---\n\n## Key Takeaways\n\n1. **~50 lines of configuration** generates **~500+ lines of production code**\n2. **Security is declarative** - you describe *what* you want, not *how*\n3. **All layers work together** - Firewall → Access → Guards → Masking\n4. **Code is readable and auditable** - no magic, just TypeScript\n5. **Type-safe from definition to API** - Drizzle schema drives everything"
|
|
155
191
|
},
|
|
156
192
|
"compiler/getting-started/hand-crafted": {
|
|
157
193
|
"title": "Hand-Crafted Setup",
|
|
@@ -203,11 +239,11 @@ export const DOCS = {
|
|
|
203
239
|
},
|
|
204
240
|
"compiler/using-the-api/crud": {
|
|
205
241
|
"title": "CRUD Endpoints",
|
|
206
|
-
"content": "Quickback automatically generates RESTful CRUD endpoints for each resource you define. This page covers how to use these endpoints.\n\n## Endpoint Overview\n\nFor a resource named `rooms`, Quickback generates:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/rooms` | List all records |\n| `GET` | `/rooms/:id` | Get a single record |\n| `POST` | `/rooms` | Create a new record |\n| `POST` | `/rooms/batch` | Batch create multiple records |\n| `PATCH` | `/rooms/:id` | Update a record |\n| `PATCH` | `/rooms/batch` | Batch update multiple records |\n| `DELETE` | `/rooms/:id` | Delete a record |\n| `DELETE` | `/rooms/batch` | Batch delete multiple records |\n| `PUT` | `/rooms/:id` | Upsert a record (requires config) |\n| `PUT` | `/rooms/batch` | Batch upsert multiple records (requires config) |\n\n## List Records\n\n```\nGET /rooms\n```\n\nReturns a paginated list of records the user has access to.\n\n### Query Parameters\n\n| Parameter | Description | Example |\n|-----------|-------------|---------|\n| `limit` | Number of records to return (default: 50, max: 100) | `?limit=25` |\n| `offset` | Number of records to skip | `?offset=50` |\n| `sort` | Field to sort by | `?sort=createdAt` |\n| `order` | Sort direction: `asc` or `desc` | `?order=desc` |\n\n### Filtering\n\nFilter records using query parameters:\n\n```\nGET /rooms?status=active # Exact match\nGET /rooms?capacity.gt=10 # Greater than\nGET /rooms?capacity.gte=10 # Greater than or equal\nGET /rooms?capacity.lt=50 # Less than\nGET /rooms?capacity.lte=50 # Less than or equal\nGET /rooms?status.ne=deleted # Not equal\nGET /rooms?name.like=Conference # Pattern match (LIKE %value%)\nGET /rooms?status.in=active,pending,review # IN clause\n```\n\n### Response\n\n```json\n{\n \"data\": [\n {\n \"id\": \"room_123\",\n \"name\": \"Conference Room A\",\n \"capacity\": 10,\n \"status\": \"active\",\n \"createdAt\": \"2024-01-15T10:30:00Z\"\n }\n ],\n \"meta\": {\n \"total\": 42,\n \"limit\": 50,\n \"offset\": 0\n }\n}\n```\n\n## Get Single Record\n\n```\nGET /rooms/:id\n```\n\nReturns a single record by ID.\n\n### Response\n\n```json\n{\n \"data\": {\n \"id\": \"room_123\",\n \"name\": \"Conference Room A\",\n \"capacity\": 10,\n \"status\": \"active\",\n \"createdAt\": \"2024-01-15T10:30:00Z\"\n }\n}\n```\n\n### Errors\n\n| Status | Description |\n|--------|-------------|\n| `404` | Record not found or not accessible |\n| `403` | User lacks permission to view this record |\n\n## Create Record\n\n```\nPOST /rooms\nContent-Type: application/json\n\n{\n \"name\": \"Conference Room B\",\n \"capacity\": 8,\n \"roomType\": \"meeting\"\n}\n```\n\nCreates a new record. Only fields listed in `guards.createable` are accepted.\n\n### Response\n\n```json\n{\n \"data\": {\n \"id\": \"room_456\",\n \"name\": \"Conference Room B\",\n \"capacity\": 8,\n \"roomType\": \"meeting\",\n \"createdAt\": \"2024-01-15T11:00:00Z\",\n \"createdBy\": \"user_789\"\n }\n}\n```\n\n### Errors\n\n| Status | Description |\n|--------|-------------|\n| `400` | Invalid field or missing required field |\n| `403` | User lacks permission to create records |\n\n## Update Record\n\n```\nPATCH /rooms/:id\nContent-Type: application/json\n\n{\n \"name\": \"Updated Room Name\",\n \"capacity\": 12\n}\n```\n\nUpdates an existing record. Only fields listed in `guards.updatable` are accepted.\n\n### Response\n\n```json\n{\n \"data\": {\n \"id\": \"room_123\",\n \"name\": \"Updated Room Name\",\n \"capacity\": 12,\n \"modifiedAt\": \"2024-01-15T12:00:00Z\",\n \"modifiedBy\": \"user_789\"\n }\n}\n```\n\n### Errors\n\n| Status | Description |\n|--------|-------------|\n| `400` | Invalid field or field not updatable |\n| `403` | User lacks permission to update this record |\n| `404` | Record not found |\n\n## Delete Record\n\n```\nDELETE /rooms/:id\n```\n\nDeletes a record. Behavior depends on the `delete.mode` configuration.\n\n### Soft Delete (default)\n\nSets `deletedAt` and `deletedBy` fields. Record remains in database but is filtered from queries.\n\n### Hard Delete\n\nPermanently removes the record from the database.\n\n### Response\n\n```json\n{\n \"data\": {\n \"id\": \"room_123\",\n \"deleted\": true\n }\n}\n```\n\n### Errors\n\n| Status | Description |\n|--------|-------------|\n| `403` | User lacks permission to delete this record |\n| `404` | Record not found |\n\n## Upsert Record (PUT)\n\n```\nPUT /rooms/:id\nContent-Type: application/json\n\n{\n \"name\": \"External Room\",\n \"capacity\": 20,\n \"externalId\": \"ext-123\"\n}\n```\n\nCreates or updates a record by ID. Requires special configuration:\n\n1. `generateId: false` in database config\n2. `guards: false` in resource definition\n\n### Behavior\n\n- If record exists: Updates all provided fields\n- If record doesn't exist: Creates with the provided ID\n\n### Use Cases\n\n- Syncing data from external systems\n- Webhook handlers with external IDs\n- Idempotent operations (safe to retry)\n\nSee [Guards documentation](/compiler/definitions/guards#putupsert-with-external-ids) for setup details.\n\n## Batch Operations\n\nQuickback provides batch endpoints for efficient bulk operations. Batch operations automatically inherit from their corresponding CRUD operations and maintain full security layer consistency.\n\n### Batch Create Records\n\n```\nPOST /rooms/batch\nContent-Type: application/json\n\n{\n \"records\": [\n { \"name\": \"Room A\", \"capacity\": 10 },\n { \"name\": \"Room B\", \"capacity\": 20 },\n { \"name\": \"Room C\", \"capacity\": 15 }\n ],\n \"options\": {\n \"atomic\": false\n }\n}\n```\n\nCreates multiple records in a single request. Each record follows the same validation rules as single create operations.\n\n#### Request Body\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `records` | Array | Array of record objects to create |\n| `options.atomic` | Boolean | If `true`, all records succeed or all fail (default: `false`) |\n\n#### Response (Partial Success - Default)\n\n```json\n{\n \"success\": [\n { \"id\": \"room_1\", \"name\": \"Room A\", \"capacity\": 10 },\n { \"id\": \"room_2\", \"name\": \"Room B\", \"capacity\": 20 }\n ],\n \"errors\": [\n {\n \"index\": 2,\n \"record\": { \"name\": \"Room C\", \"capacity\": 15 },\n \"error\": {\n \"error\": \"Field cannot be set during creation\",\n \"layer\": \"guards\",\n \"code\": \"GUARD_FIELD_NOT_CREATEABLE\",\n \"details\": { \"fields\": [\"status\"] }\n }\n }\n ],\n \"meta\": {\n \"total\": 3,\n \"succeeded\": 2,\n \"failed\": 1,\n \"atomic\": false\n }\n}\n```\n\n#### HTTP Status Codes\n\n- `201` - All records created successfully\n- `207` - Partial success (some records failed)\n- `400` - Atomic mode enabled and one or more records failed\n\n#### Batch Size Limit\n\nDefault: 100 records per request (configurable via `maxBatchSize`)\n\n### Batch Update Records\n\n```\nPATCH /rooms/batch\nContent-Type: application/json\n\n{\n \"records\": [\n { \"id\": \"room_1\", \"capacity\": 12 },\n { \"id\": \"room_2\", \"name\": \"Updated Room B\" },\n { \"id\": \"room_3\", \"capacity\": 25 }\n ],\n \"options\": {\n \"atomic\": false\n }\n}\n```\n\nUpdates multiple records in a single request. All records must include an `id` field.\n\n#### Request Body\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `records` | Array | Array of record objects with `id` and fields to update |\n| `options.atomic` | Boolean | If `true`, all records succeed or all fail (default: `false`) |\n\n#### Response\n\n```json\n{\n \"success\": [\n { \"id\": \"room_1\", \"capacity\": 12, \"modifiedAt\": \"2024-01-15T14:00:00Z\" },\n { \"id\": \"room_2\", \"name\": \"Updated Room B\", \"modifiedAt\": \"2024-01-15T14:00:00Z\" }\n ],\n \"errors\": [\n {\n \"index\": 2,\n \"record\": { \"id\": \"room_3\", \"capacity\": 25 },\n \"error\": {\n \"error\": \"Not found\",\n \"layer\": \"firewall\",\n \"code\": \"NOT_FOUND\",\n \"details\": { \"id\": \"room_3\" }\n }\n }\n ],\n \"meta\": {\n \"total\": 3,\n \"succeeded\": 2,\n \"failed\": 1,\n \"atomic\": false\n }\n}\n```\n\n#### Features\n\n- **Batch fetching**: Single database query for all IDs (with firewall)\n- **Per-record access**: Access checks run with record context\n- **Field validation**: Guards apply to each record individually\n\n### Batch Delete Records\n\n```\nDELETE /rooms/batch\nContent-Type: application/json\n\n{\n \"ids\": [\"room_1\", \"room_2\", \"room_3\"],\n \"options\": {\n \"atomic\": false\n }\n}\n```\n\nDeletes multiple records in a single request. Supports both soft and hard delete modes.\n\n#### Request Body\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `ids` | Array | Array of record IDs to delete |\n| `options.atomic` | Boolean | If `true`, all records succeed or all fail (default: `false`) |\n\n#### Response (Soft Delete)\n\n```json\n{\n \"success\": [\n { \"id\": \"room_1\", \"deletedAt\": \"2024-01-15T15:00:00Z\", \"deletedBy\": \"user_789\" },\n { \"id\": \"room_2\", \"deletedAt\": \"2024-01-15T15:00:00Z\", \"deletedBy\": \"user_789\" }\n ],\n \"errors\": [\n {\n \"index\": 2,\n \"id\": \"room_3\",\n \"error\": {\n \"error\": \"Not found\",\n \"layer\": \"firewall\",\n \"code\": \"NOT_FOUND\",\n \"details\": { \"id\": \"room_3\" }\n }\n }\n ],\n \"meta\": {\n \"total\": 3,\n \"succeeded\": 2,\n \"failed\": 1,\n \"atomic\": false\n }\n}\n```\n\n#### Delete Modes\n\n- **Soft delete** (default): Sets `deletedAt`, `deletedBy`, `modifiedAt`, `modifiedBy` fields\n- **Hard delete**: Permanently removes records from database\n\n### Batch Upsert Records\n\n```\nPUT /rooms/batch\nContent-Type: application/json\n\n{\n \"records\": [\n { \"id\": \"room_1\", \"name\": \"Updated Room A\", \"capacity\": 10 },\n { \"id\": \"new_room\", \"name\": \"New Room\", \"capacity\": 30 }\n ],\n \"options\": {\n \"atomic\": false\n }\n}\n```\n\nCreates or updates multiple records in a single request. Creates if ID doesn't exist, updates if it does.\n\n**Strict Requirements** (same as single PUT):\n1. `generateId: false` in database config (user provides IDs)\n2. `guards: false` in resource definition (no field restrictions)\n3. All records must include an `id` field\n\n**Note**: System-managed fields (`createdAt`, `createdBy`, `modifiedAt`, `modifiedBy`, `deletedAt`, `deletedBy`) are always protected and will be rejected if included in the request, regardless of guards configuration.\n\n#### Request Body\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `records` | Array | Array of record objects with `id` and all fields |\n| `options.atomic` | Boolean | If `true`, all records succeed or all fail (default: `false`) |\n\n#### Response\n\n```json\n{\n \"success\": [\n { \"id\": \"room_1\", \"name\": \"Updated Room A\", \"capacity\": 10, \"modifiedAt\": \"2024-01-15T16:00:00Z\" },\n { \"id\": \"new_room\", \"name\": \"New Room\", \"capacity\": 30, \"createdAt\": \"2024-01-15T16:00:00Z\" }\n ],\n \"errors\": [],\n \"meta\": {\n \"total\": 2,\n \"succeeded\": 2,\n \"failed\": 0,\n \"atomic\": false\n }\n}\n```\n\n#### How It Works\n\n1. Batch existence check with firewall\n2. Split records into CREATE and UPDATE batches\n3. Validate new records with `validateCreate()`\n4. Validate existing records with `validateUpdate()`\n5. Check CREATE access for new records\n6. Check UPDATE access for existing records (per-record)\n7. Execute bulk insert and individual updates\n8. Return combined results\n\n### Batch Operation Features\n\n#### Partial Success Mode (Default)\n\nBy default, batch operations use **partial success** mode:\n- All records are processed independently\n- Failed records go into `errors` array with detailed error information\n- Successful records go into `success` array\n- HTTP status `207 Multi-Status` if any errors, `201`/`200` if all success\n\n```json\n{\n \"success\": [ /* succeeded records */ ],\n \"errors\": [\n {\n \"index\": 2,\n \"record\": { /* original input */ },\n \"error\": {\n \"error\": \"Human-readable message\",\n \"layer\": \"guards\",\n \"code\": \"GUARD_FIELD_NOT_CREATEABLE\",\n \"details\": { \"fields\": [\"status\"] },\n \"hint\": \"These fields are set automatically or must be omitted\"\n }\n }\n ],\n \"meta\": {\n \"total\": 10,\n \"succeeded\": 8,\n \"failed\": 2,\n \"atomic\": false\n }\n}\n```\n\n#### Atomic Mode (Opt-in)\n\nEnable **atomic mode** for all-or-nothing behavior:\n\n```json\n{\n \"records\": [ /* ... */ ],\n \"options\": {\n \"atomic\": true\n }\n}\n```\n\n**Atomic mode behavior**:\n- First error immediately stops processing\n- All changes are rolled back (database transaction)\n- HTTP status `400 Bad Request`\n- Returns single error with failure details\n\n```json\n{\n \"error\": \"Batch operation failed in atomic mode\",\n \"layer\": \"validation\",\n \"code\": \"BATCH_ATOMIC_FAILED\",\n \"details\": {\n \"failedAt\": 2,\n \"reason\": { /* the actual error */ }\n },\n \"hint\": \"Transaction rolled back. Fix the error and retry the entire batch.\"\n}\n```\n\n#### Human-Readable Errors\n\nAll batch operation errors include:\n- **Layer identification**: Which security layer rejected the request\n- **Error code**: Machine-readable code for programmatic handling\n- **Clear message**: Human-readable explanation\n- **Details**: Contextual information (fields, IDs, reasons)\n- **Helpful hints**: Actionable guidance for resolution\n\n#### Performance Optimizations\n\n- **Batch size limits**: Default 100 records (prevents memory exhaustion)\n- **Single firewall query**: `WHERE id IN (...)` instead of N queries\n- **Bulk operations**: Single INSERT for multiple records (CREATE, UPSERT)\n- **O(1) lookups**: Map-based record lookup instead of Array.find()\n\n#### Configuration\n\nBatch operations are **auto-enabled** when corresponding CRUD operations exist:\n\n```typescript\n// Auto-enabled - no configuration needed\ncrud: {\n create: { access: { roles: ['member'] } },\n update: { access: { roles: ['member'] } }\n // batchCreate and batchUpdate automatically available\n}\n\n// Customize batch operations\ncrud: {\n create: { access: { roles: ['member'] } },\n batchCreate: {\n access: { roles: ['admin'] }, // Different access rules\n maxBatchSize: 50, // Lower limit\n allowAtomic: false // Disable atomic mode\n }\n}\n\n// Disable batch operations\ncrud: {\n create: { access: { roles: ['member'] } },\n batchCreate: false // Explicitly disable\n}\n```\n\n#### Security Layer Application\n\nBatch operations maintain **full security layer consistency**:\n\n1. **Firewall**: Auto-apply ownership fields, batch fetch with isolation\n2. **Access**: Operation-level for CREATE, per-record for UPDATE/DELETE\n3. **Guards**: Per-record field validation (same rules as single operations)\n4. **Masking**: Applied to success array (respects user permissions)\n5. **Audit**: Single timestamp for entire batch for consistency\n\n## Authentication\n\nAll endpoints require authentication. Include your auth token in the request header:\n\n```\nAuthorization: Bearer <your-token>\n```\n\nThe user's context (userId, roles, organizationId) is extracted from the token and used to:\n\n1. Apply firewall filters (data isolation)\n2. Check access permissions\n3. Set audit fields (createdBy, modifiedBy)\n\n## Error Responses\n\nAll errors use a flat structure with contextual fields:\n\n```json\n{\n \"error\": \"Insufficient permissions\",\n \"layer\": \"access\",\n \"code\": \"ACCESS_ROLE_REQUIRED\",\n \"details\": {\n \"required\": [\"admin\"],\n \"current\": [\"member\"]\n },\n \"hint\": \"Contact an administrator to grant necessary permissions\"\n}\n```\n\nSee [Errors](/compiler/using-the-api/errors) for the complete reference of error codes by security layer."
|
|
242
|
+
"content": "Quickback automatically generates RESTful CRUD endpoints for each resource you define. This page covers how to use these endpoints.\n\n## Endpoint Overview\n\nFor a resource named `rooms`, Quickback generates:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/rooms` | List all records |\n| `GET` | `/rooms/:id` | Get a single record |\n| `POST` | `/rooms` | Create a new record |\n| `POST` | `/rooms/batch` | Batch create multiple records |\n| `PATCH` | `/rooms/:id` | Update a record |\n| `PATCH` | `/rooms/batch` | Batch update multiple records |\n| `DELETE` | `/rooms/:id` | Delete a record |\n| `DELETE` | `/rooms/batch` | Batch delete multiple records |\n| `PUT` | `/rooms/:id` | Upsert a record (requires config) |\n| `PUT` | `/rooms/batch` | Batch upsert multiple records (requires config) |\n\n## List Records\n\n```\nGET /rooms\n```\n\nReturns a paginated list of records the user has access to.\n\n### Query Parameters\n\n| Parameter | Description | Example |\n|-----------|-------------|---------|\n| `limit` | Number of records to return (default: 50, max: 100) | `?limit=25` |\n| `offset` | Number of records to skip | `?offset=50` |\n| `sort` | Field to sort by | `?sort=createdAt` |\n| `order` | Sort direction: `asc` or `desc` | `?order=desc` |\n\n### Filtering\n\nFilter records using query parameters:\n\n```\nGET /rooms?status=active # Exact match\nGET /rooms?capacity.gt=10 # Greater than\nGET /rooms?capacity.gte=10 # Greater than or equal\nGET /rooms?capacity.lt=50 # Less than\nGET /rooms?capacity.lte=50 # Less than or equal\nGET /rooms?status.ne=deleted # Not equal\nGET /rooms?name.like=Conference # Pattern match (LIKE %value%)\nGET /rooms?status.in=active,pending,review # IN clause\n```\n\n### Response\n\n```json\n{\n \"data\": [\n {\n \"id\": \"room_123\",\n \"name\": \"Conference Room A\",\n \"capacity\": 10,\n \"roomTypeId\": \"rt_456\",\n \"roomType_label\": \"Conference\",\n \"status\": \"active\",\n \"createdAt\": \"2024-01-15T10:30:00Z\"\n }\n ],\n \"pagination\": {\n \"limit\": 50,\n \"offset\": 0,\n \"count\": 1\n }\n}\n```\n\n### FK Label Resolution\n\nForeign key columns are automatically enriched with `_label` fields containing the referenced table's display value. For example, `roomTypeId` gets a corresponding `roomType_label` field. See [Display Column](/compiler/definitions/schema#display-column) for details.\n\n## Get Single Record\n\n```\nGET /rooms/:id\n```\n\nReturns a single record by ID.\n\n### Response\n\n```json\n{\n \"id\": \"room_123\",\n \"name\": \"Conference Room A\",\n \"capacity\": 10,\n \"roomTypeId\": \"rt_456\",\n \"roomType_label\": \"Conference\",\n \"status\": \"active\",\n \"createdAt\": \"2024-01-15T10:30:00Z\"\n}\n```\n\n### Errors\n\n| Status | Description |\n|--------|-------------|\n| `404` | Record not found or not accessible |\n| `403` | User lacks permission to view this record |\n\n## Create Record\n\n```\nPOST /rooms\nContent-Type: application/json\n\n{\n \"name\": \"Conference Room B\",\n \"capacity\": 8,\n \"roomType\": \"meeting\"\n}\n```\n\nCreates a new record. Only fields listed in `guards.createable` are accepted.\n\n### Response\n\n```json\n{\n \"data\": {\n \"id\": \"room_456\",\n \"name\": \"Conference Room B\",\n \"capacity\": 8,\n \"roomType\": \"meeting\",\n \"createdAt\": \"2024-01-15T11:00:00Z\",\n \"createdBy\": \"user_789\"\n }\n}\n```\n\n### Errors\n\n| Status | Description |\n|--------|-------------|\n| `400` | Invalid field or missing required field |\n| `403` | User lacks permission to create records |\n\n## Update Record\n\n```\nPATCH /rooms/:id\nContent-Type: application/json\n\n{\n \"name\": \"Updated Room Name\",\n \"capacity\": 12\n}\n```\n\nUpdates an existing record. Only fields listed in `guards.updatable` are accepted.\n\n### Response\n\n```json\n{\n \"data\": {\n \"id\": \"room_123\",\n \"name\": \"Updated Room Name\",\n \"capacity\": 12,\n \"modifiedAt\": \"2024-01-15T12:00:00Z\",\n \"modifiedBy\": \"user_789\"\n }\n}\n```\n\n### Errors\n\n| Status | Description |\n|--------|-------------|\n| `400` | Invalid field or field not updatable |\n| `403` | User lacks permission to update this record |\n| `404` | Record not found |\n\n## Delete Record\n\n```\nDELETE /rooms/:id\n```\n\nDeletes a record. Behavior depends on the `delete.mode` configuration.\n\n### Soft Delete (default)\n\nSets `deletedAt` and `deletedBy` fields. Record remains in database but is filtered from queries.\n\n### Hard Delete\n\nPermanently removes the record from the database.\n\n### Response\n\n```json\n{\n \"data\": {\n \"id\": \"room_123\",\n \"deleted\": true\n }\n}\n```\n\n### Errors\n\n| Status | Description |\n|--------|-------------|\n| `403` | User lacks permission to delete this record |\n| `404` | Record not found |\n\n## Upsert Record (PUT)\n\n```\nPUT /rooms/:id\nContent-Type: application/json\n\n{\n \"name\": \"External Room\",\n \"capacity\": 20,\n \"externalId\": \"ext-123\"\n}\n```\n\nCreates or updates a record by ID. Requires special configuration:\n\n1. `generateId: false` in database config\n2. `guards: false` in resource definition\n\n### Behavior\n\n- If record exists: Updates all provided fields\n- If record doesn't exist: Creates with the provided ID\n\n### Use Cases\n\n- Syncing data from external systems\n- Webhook handlers with external IDs\n- Idempotent operations (safe to retry)\n\nSee [Guards documentation](/compiler/definitions/guards#putupsert-with-external-ids) for setup details.\n\n## Batch Operations\n\nQuickback provides batch endpoints for efficient bulk operations. Batch operations automatically inherit from their corresponding CRUD operations and maintain full security layer consistency.\n\n### Batch Create Records\n\n```\nPOST /rooms/batch\nContent-Type: application/json\n\n{\n \"records\": [\n { \"name\": \"Room A\", \"capacity\": 10 },\n { \"name\": \"Room B\", \"capacity\": 20 },\n { \"name\": \"Room C\", \"capacity\": 15 }\n ],\n \"options\": {\n \"atomic\": false\n }\n}\n```\n\nCreates multiple records in a single request. Each record follows the same validation rules as single create operations.\n\n#### Request Body\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `records` | Array | Array of record objects to create |\n| `options.atomic` | Boolean | If `true`, all records succeed or all fail (default: `false`) |\n\n#### Response (Partial Success - Default)\n\n```json\n{\n \"success\": [\n { \"id\": \"room_1\", \"name\": \"Room A\", \"capacity\": 10 },\n { \"id\": \"room_2\", \"name\": \"Room B\", \"capacity\": 20 }\n ],\n \"errors\": [\n {\n \"index\": 2,\n \"record\": { \"name\": \"Room C\", \"capacity\": 15 },\n \"error\": {\n \"error\": \"Field cannot be set during creation\",\n \"layer\": \"guards\",\n \"code\": \"GUARD_FIELD_NOT_CREATEABLE\",\n \"details\": { \"fields\": [\"status\"] }\n }\n }\n ],\n \"meta\": {\n \"total\": 3,\n \"succeeded\": 2,\n \"failed\": 1,\n \"atomic\": false\n }\n}\n```\n\n#### HTTP Status Codes\n\n- `201` - All records created successfully\n- `207` - Partial success (some records failed)\n- `400` - Atomic mode enabled and one or more records failed\n\n#### Batch Size Limit\n\nDefault: 100 records per request (configurable via `maxBatchSize`)\n\n### Batch Update Records\n\n```\nPATCH /rooms/batch\nContent-Type: application/json\n\n{\n \"records\": [\n { \"id\": \"room_1\", \"capacity\": 12 },\n { \"id\": \"room_2\", \"name\": \"Updated Room B\" },\n { \"id\": \"room_3\", \"capacity\": 25 }\n ],\n \"options\": {\n \"atomic\": false\n }\n}\n```\n\nUpdates multiple records in a single request. All records must include an `id` field.\n\n#### Request Body\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `records` | Array | Array of record objects with `id` and fields to update |\n| `options.atomic` | Boolean | If `true`, all records succeed or all fail (default: `false`) |\n\n#### Response\n\n```json\n{\n \"success\": [\n { \"id\": \"room_1\", \"capacity\": 12, \"modifiedAt\": \"2024-01-15T14:00:00Z\" },\n { \"id\": \"room_2\", \"name\": \"Updated Room B\", \"modifiedAt\": \"2024-01-15T14:00:00Z\" }\n ],\n \"errors\": [\n {\n \"index\": 2,\n \"record\": { \"id\": \"room_3\", \"capacity\": 25 },\n \"error\": {\n \"error\": \"Not found\",\n \"layer\": \"firewall\",\n \"code\": \"NOT_FOUND\",\n \"details\": { \"id\": \"room_3\" }\n }\n }\n ],\n \"meta\": {\n \"total\": 3,\n \"succeeded\": 2,\n \"failed\": 1,\n \"atomic\": false\n }\n}\n```\n\n#### Features\n\n- **Batch fetching**: Single database query for all IDs (with firewall)\n- **Per-record access**: Access checks run with record context\n- **Field validation**: Guards apply to each record individually\n\n### Batch Delete Records\n\n```\nDELETE /rooms/batch\nContent-Type: application/json\n\n{\n \"ids\": [\"room_1\", \"room_2\", \"room_3\"],\n \"options\": {\n \"atomic\": false\n }\n}\n```\n\nDeletes multiple records in a single request. Supports both soft and hard delete modes.\n\n#### Request Body\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `ids` | Array | Array of record IDs to delete |\n| `options.atomic` | Boolean | If `true`, all records succeed or all fail (default: `false`) |\n\n#### Response (Soft Delete)\n\n```json\n{\n \"success\": [\n { \"id\": \"room_1\", \"deletedAt\": \"2024-01-15T15:00:00Z\", \"deletedBy\": \"user_789\" },\n { \"id\": \"room_2\", \"deletedAt\": \"2024-01-15T15:00:00Z\", \"deletedBy\": \"user_789\" }\n ],\n \"errors\": [\n {\n \"index\": 2,\n \"id\": \"room_3\",\n \"error\": {\n \"error\": \"Not found\",\n \"layer\": \"firewall\",\n \"code\": \"NOT_FOUND\",\n \"details\": { \"id\": \"room_3\" }\n }\n }\n ],\n \"meta\": {\n \"total\": 3,\n \"succeeded\": 2,\n \"failed\": 1,\n \"atomic\": false\n }\n}\n```\n\n#### Delete Modes\n\n- **Soft delete** (default): Sets `deletedAt`, `deletedBy`, `modifiedAt`, `modifiedBy` fields\n- **Hard delete**: Permanently removes records from database\n\n### Batch Upsert Records\n\n```\nPUT /rooms/batch\nContent-Type: application/json\n\n{\n \"records\": [\n { \"id\": \"room_1\", \"name\": \"Updated Room A\", \"capacity\": 10 },\n { \"id\": \"new_room\", \"name\": \"New Room\", \"capacity\": 30 }\n ],\n \"options\": {\n \"atomic\": false\n }\n}\n```\n\nCreates or updates multiple records in a single request. Creates if ID doesn't exist, updates if it does.\n\n**Strict Requirements** (same as single PUT):\n1. `generateId: false` in database config (user provides IDs)\n2. `guards: false` in resource definition (no field restrictions)\n3. All records must include an `id` field\n\n**Note**: System-managed fields (`createdAt`, `createdBy`, `modifiedAt`, `modifiedBy`, `deletedAt`, `deletedBy`) are always protected and will be rejected if included in the request, regardless of guards configuration.\n\n#### Request Body\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `records` | Array | Array of record objects with `id` and all fields |\n| `options.atomic` | Boolean | If `true`, all records succeed or all fail (default: `false`) |\n\n#### Response\n\n```json\n{\n \"success\": [\n { \"id\": \"room_1\", \"name\": \"Updated Room A\", \"capacity\": 10, \"modifiedAt\": \"2024-01-15T16:00:00Z\" },\n { \"id\": \"new_room\", \"name\": \"New Room\", \"capacity\": 30, \"createdAt\": \"2024-01-15T16:00:00Z\" }\n ],\n \"errors\": [],\n \"meta\": {\n \"total\": 2,\n \"succeeded\": 2,\n \"failed\": 0,\n \"atomic\": false\n }\n}\n```\n\n#### How It Works\n\n1. Batch existence check with firewall\n2. Split records into CREATE and UPDATE batches\n3. Validate new records with `validateCreate()`\n4. Validate existing records with `validateUpdate()`\n5. Check CREATE access for new records\n6. Check UPDATE access for existing records (per-record)\n7. Execute bulk insert and individual updates\n8. Return combined results\n\n### Batch Operation Features\n\n#### Partial Success Mode (Default)\n\nBy default, batch operations use **partial success** mode:\n- All records are processed independently\n- Failed records go into `errors` array with detailed error information\n- Successful records go into `success` array\n- HTTP status `207 Multi-Status` if any errors, `201`/`200` if all success\n\n```json\n{\n \"success\": [ /* succeeded records */ ],\n \"errors\": [\n {\n \"index\": 2,\n \"record\": { /* original input */ },\n \"error\": {\n \"error\": \"Human-readable message\",\n \"layer\": \"guards\",\n \"code\": \"GUARD_FIELD_NOT_CREATEABLE\",\n \"details\": { \"fields\": [\"status\"] },\n \"hint\": \"These fields are set automatically or must be omitted\"\n }\n }\n ],\n \"meta\": {\n \"total\": 10,\n \"succeeded\": 8,\n \"failed\": 2,\n \"atomic\": false\n }\n}\n```\n\n#### Atomic Mode (Opt-in)\n\nEnable **atomic mode** for all-or-nothing behavior:\n\n```json\n{\n \"records\": [ /* ... */ ],\n \"options\": {\n \"atomic\": true\n }\n}\n```\n\n**Atomic mode behavior**:\n- First error immediately stops processing\n- All changes are rolled back (database transaction)\n- HTTP status `400 Bad Request`\n- Returns single error with failure details\n\n```json\n{\n \"error\": \"Batch operation failed in atomic mode\",\n \"layer\": \"validation\",\n \"code\": \"BATCH_ATOMIC_FAILED\",\n \"details\": {\n \"failedAt\": 2,\n \"reason\": { /* the actual error */ }\n },\n \"hint\": \"Transaction rolled back. Fix the error and retry the entire batch.\"\n}\n```\n\n#### Human-Readable Errors\n\nAll batch operation errors include:\n- **Layer identification**: Which security layer rejected the request\n- **Error code**: Machine-readable code for programmatic handling\n- **Clear message**: Human-readable explanation\n- **Details**: Contextual information (fields, IDs, reasons)\n- **Helpful hints**: Actionable guidance for resolution\n\n#### Performance Optimizations\n\n- **Batch size limits**: Default 100 records (prevents memory exhaustion)\n- **Single firewall query**: `WHERE id IN (...)` instead of N queries\n- **Bulk operations**: Single INSERT for multiple records (CREATE, UPSERT)\n- **O(1) lookups**: Map-based record lookup instead of Array.find()\n\n#### Configuration\n\nBatch operations are **auto-enabled** when corresponding CRUD operations exist:\n\n```typescript\n// Auto-enabled - no configuration needed\ncrud: {\n create: { access: { roles: ['member'] } },\n update: { access: { roles: ['member'] } }\n // batchCreate and batchUpdate automatically available\n}\n\n// Customize batch operations\ncrud: {\n create: { access: { roles: ['member'] } },\n batchCreate: {\n access: { roles: ['admin'] }, // Different access rules\n maxBatchSize: 50, // Lower limit\n allowAtomic: false // Disable atomic mode\n }\n}\n\n// Disable batch operations\ncrud: {\n create: { access: { roles: ['member'] } },\n batchCreate: false // Explicitly disable\n}\n```\n\n#### Security Layer Application\n\nBatch operations maintain **full security layer consistency**:\n\n1. **Firewall**: Auto-apply ownership fields, batch fetch with isolation\n2. **Access**: Operation-level for CREATE, per-record for UPDATE/DELETE\n3. **Guards**: Per-record field validation (same rules as single operations)\n4. **Masking**: Applied to success array (respects user permissions)\n5. **Audit**: Single timestamp for entire batch for consistency\n\n## Authentication\n\nAll endpoints require authentication. Include your auth token in the request header:\n\n```\nAuthorization: Bearer <your-token>\n```\n\nThe user's context (userId, roles, organizationId) is extracted from the token and used to:\n\n1. Apply firewall filters (data isolation)\n2. Check access permissions\n3. Set audit fields (createdBy, modifiedBy)\n\n## Error Responses\n\nAll errors use a flat structure with contextual fields:\n\n```json\n{\n \"error\": \"Insufficient permissions\",\n \"layer\": \"access\",\n \"code\": \"ACCESS_ROLE_REQUIRED\",\n \"details\": {\n \"required\": [\"admin\"],\n \"current\": [\"member\"]\n },\n \"hint\": \"Contact an administrator to grant necessary permissions\"\n}\n```\n\nSee [Errors](/compiler/using-the-api/errors) for the complete reference of error codes by security layer."
|
|
207
243
|
},
|
|
208
244
|
"compiler/using-the-api/errors": {
|
|
209
245
|
"title": "Errors",
|
|
210
|
-
"content": "The generated API returns structured error responses with consistent fields across all security layers.\n\n## Status Codes\n\n| Code | Meaning | When |\n|------|---------|------|\n| `200` | OK | Successful GET, PATCH, DELETE |\n| `201` | Created | Successful POST |\n| `207` | Multi-Status | Batch operation with mixed results |\n| `400` | Bad Request | Guard violation, validation error, invalid input |\n| `401` | Unauthorized | Missing or expired authentication |\n| `403` | Forbidden | Access denied (wrong role, condition failed) |\n| `404` | Not Found | Record doesn't exist or firewall blocked
|
|
246
|
+
"content": "The generated API returns structured error responses with consistent fields across all security layers.\n\n## Status Codes\n\n| Code | Meaning | When |\n|------|---------|------|\n| `200` | OK | Successful GET, PATCH, DELETE |\n| `201` | Created | Successful POST |\n| `207` | Multi-Status | Batch operation with mixed results |\n| `400` | Bad Request | Guard violation, validation error, invalid input |\n| `401` | Unauthorized | Missing or expired authentication |\n| `403` | Forbidden | Access denied (wrong role, condition failed) or firewall blocked it |\n| `404` | Not Found | Record doesn't exist (or firewall blocked with `errorMode: 'hide'`) |\n| `429` | Too Many Requests | Rate limit exceeded |\n\n## Error Response Format\n\nAll errors use a flat structure with contextual fields:\n\n```json\n{\n \"error\": \"Insufficient permissions\",\n \"layer\": \"access\",\n \"code\": \"ACCESS_ROLE_REQUIRED\",\n \"details\": {\n \"required\": [\"admin\"],\n \"current\": [\"member\"]\n },\n \"hint\": \"Contact an administrator to grant necessary permissions\"\n}\n```\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `error` | string | Human-readable error message |\n| `layer` | string | Security layer that rejected the request |\n| `code` | string | Machine-readable error code |\n| `details` | object | Layer-specific context (optional) |\n| `hint` | string | Actionable guidance for resolution (optional) |\n\n## Errors by Security Layer\n\n### Authentication (401)\n\nMissing or invalid authentication tokens.\n\n```json\n{\n \"error\": \"Authentication required\",\n \"layer\": \"authentication\",\n \"code\": \"AUTH_MISSING\",\n \"hint\": \"Include Authorization header with Bearer token\"\n}\n```\n\n**Error codes:**\n\n| Code | Description |\n|------|-------------|\n| `AUTH_MISSING` | No Authorization header provided |\n| `AUTH_INVALID_TOKEN` | Token is malformed or invalid |\n| `AUTH_EXPIRED` | Token has expired |\n| `AUTH_RATE_LIMITED` | Too many auth attempts |\n\n### Firewall (403)\n\nRecords outside the user's firewall scope return **403 Forbidden** by default with a structured error:\n\n```json\n{\n \"error\": \"Record not found or not accessible\",\n \"layer\": \"firewall\",\n \"code\": \"FIREWALL_NOT_FOUND\",\n \"hint\": \"Check the record ID and your organization membership\"\n}\n```\n\nFirewall filtering is transparent — the query is scoped by `WHERE organizationId = ?` so inaccessible records simply don't appear in results.\n\n**Error codes:**\n\n| Code | Description |\n|------|-------------|\n| `FIREWALL_NOT_FOUND` | Record not found behind firewall (wrong org, soft-deleted, or doesn't exist) |\n| `FIREWALL_ORG_ISOLATION` | Record belongs to a different organization |\n| `FIREWALL_USER_ISOLATION` | Record belongs to another user |\n| `FIREWALL_SOFT_DELETED` | Record has been soft deleted |\n\nFor security-hardened deployments, set `errorMode: 'hide'` in your firewall config to return opaque **404 Not Found** responses instead. This prevents attackers from distinguishing between \"record exists but you can't access it\" and \"record doesn't exist\".\n\n### Access (403)\n\nAccess violations return **403 Forbidden** when the user's role doesn't match the required roles for the operation.\n\n```json\n{\n \"error\": \"Insufficient permissions\",\n \"layer\": \"access\",\n \"code\": \"ACCESS_ROLE_REQUIRED\",\n \"details\": {\n \"required\": [\"admin\"],\n \"current\": [\"member\"]\n },\n \"hint\": \"Contact an administrator to grant necessary permissions\"\n}\n```\n\n**Error codes:**\n\n| Code | Description |\n|------|-------------|\n| `ACCESS_ROLE_REQUIRED` | User doesn't have the required role |\n| `ACCESS_CONDITION_FAILED` | Record-level access condition not met |\n| `ACCESS_OWNERSHIP_REQUIRED` | User must own the record |\n| `ACCESS_NO_ORG` | No active organization set |\n\n### Guards (400)\n\nGuard violations return **400 Bad Request** when the request body contains fields that aren't allowed.\n\n```json\n{\n \"error\": \"Field cannot be set during creation\",\n \"layer\": \"guards\",\n \"code\": \"GUARD_FIELD_NOT_CREATEABLE\",\n \"details\": {\n \"fields\": [\"status\"]\n },\n \"hint\": \"These fields are set automatically or must be omitted\"\n}\n```\n\n**Error codes:**\n\n| Code | Description |\n|------|-------------|\n| `GUARD_FIELD_NOT_CREATEABLE` | Field not in `createable` list |\n| `GUARD_FIELD_NOT_UPDATABLE` | Field not in `updatable` list |\n| `GUARD_FIELD_PROTECTED` | Field is action-only (protected) |\n| `GUARD_FIELD_IMMUTABLE` | Field cannot be modified after creation |\n| `GUARD_SYSTEM_MANAGED` | System field (createdAt, modifiedAt, etc.) |\n\n### Masking\n\nMasking doesn't produce errors — it silently transforms field values in the response.\n\n## Batch Errors\n\nBatch operations can return:\n- **201** — All records succeeded\n- **207** — Partial success (some records failed)\n- **400** — Atomic mode and at least one record failed (all rolled back)\n\n### Partial Success (207)\n\n```json\n{\n \"success\": [{ \"id\": \"room_1\", \"name\": \"Room A\" }],\n \"errors\": [\n {\n \"index\": 1,\n \"record\": { \"name\": \"Room B\", \"status\": \"active\" },\n \"error\": {\n \"error\": \"Field cannot be set during creation\",\n \"layer\": \"guards\",\n \"code\": \"GUARD_FIELD_NOT_CREATEABLE\",\n \"details\": { \"fields\": [\"status\"] },\n \"hint\": \"These fields are set automatically or must be omitted\"\n }\n }\n ],\n \"meta\": { \"total\": 2, \"succeeded\": 1, \"failed\": 1, \"atomic\": false }\n}\n```\n\n### Atomic Failure (400)\n\n```json\n{\n \"error\": \"Batch operation failed in atomic mode\",\n \"layer\": \"validation\",\n \"code\": \"BATCH_ATOMIC_FAILED\",\n \"details\": {\n \"failedAt\": 2,\n \"reason\": { \"error\": \"Not found\", \"code\": \"NOT_FOUND\" }\n },\n \"hint\": \"Transaction rolled back. Fix the error and retry the entire batch.\"\n}\n```\n\n**Batch error codes:**\n\n| Code | Description |\n|------|-------------|\n| `BATCH_SIZE_EXCEEDED` | Too many records in a single request |\n| `BATCH_ATOMIC_FAILED` | Atomic batch failed, all changes rolled back |\n| `BATCH_MISSING_IDS` | Batch update/delete missing required IDs |"
|
|
211
247
|
},
|
|
212
248
|
"compiler/using-the-api": {
|
|
213
249
|
"title": "Using the API",
|
|
@@ -223,7 +259,7 @@ export const DOCS = {
|
|
|
223
259
|
},
|
|
224
260
|
"compiler/using-the-api/views-api": {
|
|
225
261
|
"title": "Views API",
|
|
226
|
-
"content": "Views provide named column projections that limit which fields are returned in API responses. Each view can have its own access control, and all security pillars (firewall, masking) still apply.\n\n## Endpoint\n\n```\nGET /api/v1/{resource}/views/{view-name}\n```\n\n## Example\n\nGiven this definition:\n\n```typescript\nexport default defineTable(customers, {\n views: {\n summary: {\n fields: ['id', 'name', 'status'],\n access: { roles: ['owner', 'admin', 'member'] },\n },\n full: {\n fields: ['id', 'name', 'email', 'status', 'ssn', 'internalNotes'],\n access: { roles: ['owner', 'admin'] },\n },\n },\n // ...\n});\n```\n\n### Request\n\n```bash\ncurl /api/v1/customers/views/summary \\\n -H \"Authorization: Bearer <token>\"\n```\n\n### Response\n\n```json\n{\n \"data\": [\n { \"id\": \"cust_001\", \"name\": \"Acme Corp\", \"status\": \"active\" },\n { \"id\": \"cust_002\", \"name\": \"Widget Inc\", \"status\": \"pending\" }\n ],\n \"view\": \"summary\",\n \"fields\": [\"id\", \"name\", \"status\"],\n \"pagination\": {\n \"limit\": 50,\n \"offset\": 0,\n \"count\": 2\n }\n}\n```\n\nOnly the fields specified in the view definition are returned. Requesting the `full` view with a `member` role returns 403.\n\n## Query Parameters\n\nViews support the same query parameters as the list endpoint:\n\n| Parameter | Description | Default |\n|-----------|-------------|---------|\n| `limit` | Number of records to return (1–100) | `50` |\n| `offset` | Number of records to skip | `0` |\n| `sort` | Field to sort by | `createdAt` |\n| `order` | Sort direction (`asc` or `desc`) | `desc` |\n| `{field}` | Filter by exact value | — |\n| `{field}.gt` | Greater than | — |\n| `{field}.gte` | Greater than or equal | — |\n| `{field}.lt` | Less than | — |\n| `{field}.lte` | Less than or equal | — |\n| `{field}.ne` | Not equal | — |\n| `{field}.like` | Pattern match (SQL LIKE) | — |\n| `{field}.in` | Match any value in comma-separated list | — |\n\n### Examples\n\n```bash\n# Paginated with sorting\nGET /api/v1/customers/views/summary?limit=10&offset=0&sort=name&order=asc\n\n# With filters\nGET /api/v1/customers/views/summary?status=active&name.like=%25Corp%25\n\n# Combined\nGET /api/v1/customers/views/summary?status.in=active,pending&sort=name&order=asc&limit=25\n```\n\n## Security\n\nAll security pillars apply to view endpoints:\n\n1. **Authentication** — Required (401 if missing)\n2. **Firewall** — Organization/user isolation applied to all results. Only records the user owns or has access to are returned.\n3. **Access** — Per-view role check. Each view can require different roles.\n4. **Masking** — Applied to all returned fields. If a masked field is in the view's field list, the masking rules still apply based on the user's role.\n\n### Access Control Per View\n\nDifferent views can require different permission levels:\n\n```typescript\nviews: {\n // Available to all org members\n summary: {\n fields: ['id', 'name', 'status'],\n access: { roles: ['owner', 'admin', 'member'] },\n },\n // Admin-only view with sensitive data\n full: {\n fields: ['id', 'name', 'email', 'ssn', 'internalNotes'],\n access: { roles: ['owner', 'admin'] },\n },\n}\n```\n\nA `member` requesting the `full` view receives:\n\n```json\n{\n \"error\": \"Insufficient permissions\",\n \"layer\": \"access\",\n \"code\": \"ACCESS_ROLE_REQUIRED\",\n \"details\": { \"required\": [\"admin\"], \"current\": [\"member\"] }\n}\n```\n\n### Masking in Views\n\nMasking is applied after field projection. If your masking config hides `ssn` from non-admins:\n\n```typescript\nmasking: {\n ssn: { type: 'ssn', show: { roles: ['admin'] } },\n}\n```\n\nA `member` requesting a view that includes `ssn` will see the masked value (e.g.,
|
|
262
|
+
"content": "Views provide named column projections that limit which fields are returned in API responses. Each view can have its own access control, and all security pillars (firewall, masking) still apply.\n\n## Endpoint\n\n```\nGET /api/v1/{resource}/views/{view-name}\n```\n\n## Example\n\nGiven this definition:\n\n```typescript\nexport default defineTable(customers, {\n views: {\n summary: {\n fields: ['id', 'name', 'status'],\n access: { roles: ['owner', 'admin', 'member'] },\n },\n full: {\n fields: ['id', 'name', 'email', 'status', 'ssn', 'internalNotes'],\n access: { roles: ['owner', 'admin'] },\n },\n },\n // ...\n});\n```\n\n### Request\n\n```bash\ncurl /api/v1/customers/views/summary \\\n -H \"Authorization: Bearer <token>\"\n```\n\n### Response\n\n```json\n{\n \"data\": [\n { \"id\": \"cust_001\", \"name\": \"Acme Corp\", \"status\": \"active\" },\n { \"id\": \"cust_002\", \"name\": \"Widget Inc\", \"status\": \"pending\" }\n ],\n \"view\": \"summary\",\n \"fields\": [\"id\", \"name\", \"status\"],\n \"pagination\": {\n \"limit\": 50,\n \"offset\": 0,\n \"count\": 2\n }\n}\n```\n\nOnly the fields specified in the view definition are returned. Requesting the `full` view with a `member` role returns 403.\n\n## Query Parameters\n\nViews support the same query parameters as the list endpoint:\n\n| Parameter | Description | Default |\n|-----------|-------------|---------|\n| `limit` | Number of records to return (1–100) | `50` |\n| `offset` | Number of records to skip | `0` |\n| `sort` | Field to sort by | `createdAt` |\n| `order` | Sort direction (`asc` or `desc`) | `desc` |\n| `{field}` | Filter by exact value | — |\n| `{field}.gt` | Greater than | — |\n| `{field}.gte` | Greater than or equal | — |\n| `{field}.lt` | Less than | — |\n| `{field}.lte` | Less than or equal | — |\n| `{field}.ne` | Not equal | — |\n| `{field}.like` | Pattern match (SQL LIKE) | — |\n| `{field}.in` | Match any value in comma-separated list | — |\n\n### Examples\n\n```bash\n# Paginated with sorting\nGET /api/v1/customers/views/summary?limit=10&offset=0&sort=name&order=asc\n\n# With filters\nGET /api/v1/customers/views/summary?status=active&name.like=%25Corp%25\n\n# Combined\nGET /api/v1/customers/views/summary?status.in=active,pending&sort=name&order=asc&limit=25\n```\n\n## Security\n\nAll security pillars apply to view endpoints:\n\n1. **Authentication** — Required (401 if missing)\n2. **Firewall** — Organization/user isolation applied to all results. Only records the user owns or has access to are returned.\n3. **Access** — Per-view role check. Each view can require different roles.\n4. **Masking** — Applied to all returned fields. If a masked field is in the view's field list, the masking rules still apply based on the user's role.\n\n### Access Control Per View\n\nDifferent views can require different permission levels:\n\n```typescript\nviews: {\n // Available to all org members\n summary: {\n fields: ['id', 'name', 'status'],\n access: { roles: ['owner', 'admin', 'member'] },\n },\n // Admin-only view with sensitive data\n full: {\n fields: ['id', 'name', 'email', 'ssn', 'internalNotes'],\n access: { roles: ['owner', 'admin'] },\n },\n}\n```\n\nA `member` requesting the `full` view receives:\n\n```json\n{\n \"error\": \"Insufficient permissions\",\n \"layer\": \"access\",\n \"code\": \"ACCESS_ROLE_REQUIRED\",\n \"details\": { \"required\": [\"admin\"], \"current\": [\"member\"] }\n}\n```\n\n### Masking in Views\n\nMasking is applied after field projection. If your masking config hides `ssn` from non-admins:\n\n```typescript\nmasking: {\n ssn: { type: 'ssn', show: { roles: ['admin'] } },\n}\n```\n\nA `member` requesting a view that includes `ssn` will see the masked value (e.g., `*****1234`), while an `admin` sees the full value.\n\n## See Also\n\n- [Defining Views](/compiler/definitions/views) — How to configure views in defineTable\n- [Query Parameters](/compiler/using-the-api/query-params) — Full query parameter reference\n- [Access Control](/compiler/definitions/access) — Role-based access configuration"
|
|
227
263
|
},
|
|
228
264
|
"index": {
|
|
229
265
|
"title": "Quickback Documentation",
|
|
@@ -307,7 +343,7 @@ export const DOCS = {
|
|
|
307
343
|
},
|
|
308
344
|
"stack/realtime/durable-objects": {
|
|
309
345
|
"title": "Realtime",
|
|
310
|
-
"content": "Quickback can generate realtime notification helpers for broadcasting changes to connected clients. This enables live updates via WebSocket using Cloudflare Durable Objects.\n\n## Enabling Realtime\n\nAdd `realtime` configuration to individual table definitions:\n\n```typescript\n// features/claims/claims.ts\n\nexport const claims = sqliteTable(\"claims\", {\n id: text(\"id\").primaryKey(),\n title: text(\"title\").notNull(),\n status: text(\"status\").notNull(),\n organizationId: text(\"organization_id\").notNull(),\n});\n\nexport default defineTable(claims, {\n firewall: { organization: {} },\n realtime: {\n enabled: true, // Enable realtime for this table\n onInsert: true, // Broadcast on INSERT (default: true)\n onUpdate: true, // Broadcast on UPDATE (default: true)\n onDelete: true, // Broadcast on DELETE (default: true)\n requiredRoles: [\"member\", \"admin\"], // Who receives broadcasts\n fields: [\"id\", \"title\", \"status\"], // Fields to include (optional)\n },\n // ... guards, crud\n});\n```\n\nWhen any table has realtime enabled, the compiler generates `src/lib/realtime.ts` with helper functions for sending notifications.\n\n## Generated Helper\n\nThe `createRealtime()` factory provides convenient methods for both Postgres Changes (CRUD events) and custom broadcasts:\n\n```typescript\n\nexport const execute: ActionExecutor = async ({ db, ctx, input }) => {\n const realtime = createRealtime(ctx.env);\n\n // After creating a record\n await realtime.insert('claims', newClaim, ctx.activeOrgId!);\n\n // After updating a record\n await realtime.update('claims', newClaim, oldClaim, ctx.activeOrgId!);\n\n // After deleting a record\n await realtime.delete('claims', { id: claimId }, ctx.activeOrgId!);\n\n return { success: true };\n};\n```\n\n## Event Format\n\nQuickback broadcasts events in a structured JSON format for easy client-side handling.\n\n### Insert Event\n\n```typescript\nawait realtime.insert('materials', {\n id: 'mat_123',\n title: 'Breaking News',\n status: 'pending'\n}, ctx.activeOrgId!);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"materials\",\n \"eventType\": \"INSERT\",\n \"schema\": \"public\",\n \"new\": {\n \"id\": \"mat_123\",\n \"title\": \"Breaking News\",\n \"status\": \"pending\"\n },\n \"old\": null\n}\n```\n\n### Update Event\n\n```typescript\nawait realtime.update('materials',\n { id: 'mat_123', status: 'completed' }, // new\n { id: 'mat_123', status: 'pending' }, // old\n ctx.activeOrgId!\n);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"materials\",\n \"eventType\": \"UPDATE\",\n \"schema\": \"public\",\n \"new\": { \"id\": \"mat_123\", \"status\": \"completed\" },\n \"old\": { \"id\": \"mat_123\", \"status\": \"pending\" }\n}\n```\n\n### Delete Event\n\n```typescript\nawait realtime.delete('materials',\n { id: 'mat_123' },\n ctx.activeOrgId!\n);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"materials\",\n \"eventType\": \"DELETE\",\n \"schema\": \"public\",\n \"new\": null,\n \"old\": { \"id\": \"mat_123\" }\n}\n```\n\n## Custom Broadcasts\n\nFor arbitrary events that don't map to CRUD operations:\n\n```typescript\nawait realtime.broadcast('processing-complete', {\n materialId: 'mat_123',\n claimCount: 5,\n duration: 1234\n}, ctx.activeOrgId!);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"broadcast\",\n \"event\": \"processing-complete\",\n \"payload\": {\n \"materialId\": \"mat_123\",\n \"claimCount\": 5,\n \"duration\": 1234\n }\n}\n```\n\n## User-Specific Broadcasts\n\nTarget a specific user instead of the entire organization:\n\n```typescript\n// Only this user receives the notification\nawait realtime.insert('notifications', newNotification, ctx.activeOrgId!, {\n userId: ctx.userId\n});\n\nawait realtime.broadcast('task-assigned', {\n taskId: 'task_123'\n}, ctx.activeOrgId!, {\n userId: assigneeId\n});\n```\n\n## Role-Based Filtering\n\nLimit which roles receive a broadcast using `targetRoles`:\n\n```typescript\n// Only admins and editors receive this broadcast\nawait realtime.insert('admin-actions', action, ctx.activeOrgId!, {\n targetRoles: ['admin', 'editor']\n});\n\n// Members won't see this update\nawait realtime.update('sensitive-data', newRecord, oldRecord, ctx.activeOrgId!, {\n targetRoles: ['admin']\n});\n```\n\nIf `targetRoles` is not specified, all authenticated users in the organization receive the broadcast.\n\n## Per-Role Field Masking\n\nApply different field masking based on the subscriber's role using `maskingConfig`:\n\n```typescript\nawait realtime.insert('employees', newEmployee, ctx.activeOrgId!, {\n maskingConfig: {\n ssn: { type: 'ssn', show: { roles: ['admin', 'hr'] } },\n salary: { type: 'redact', show: { roles: ['admin'] } },\n email: { type: 'email', show: { roles: ['admin', 'hr', 'manager'] } },\n },\n});\n```\n\n**How masking works:**\n- Each subscriber receives a payload masked according to their role\n- Roles in the `show.roles` array see unmasked values\n- All other roles see the masked version\n- Masking is pre-computed per-role (O(roles) not O(subscribers))\n\n**Available mask types:**\n| Type | Example Output |\n|------|----------------|\n| `email` | `j***@y***.com` |\n| `phone` | `***-***-4567` |\n| `ssn` | `***-**-6789` |\n| `creditCard` | `**** **** **** 1111` |\n| `name` | `J***` |\n| `redact` | `[REDACTED]` |\n\n### Owner-Based Masking\n\nShow unmasked data to the record owner:\n\n```typescript\nmaskingConfig: {\n ssn: {\n type: 'ssn',\n show: { roles: ['admin'], or: 'owner' } // Admin OR owner sees unmasked\n },\n}\n```\n\n## Client-Side Subscription\n\n### WebSocket Authentication\n\nThe Broadcaster supports two authentication methods:\n\n**Session Token (Browser/App):**\n```typescript\nconst ws = new WebSocket('wss://api.yourdomain.com/realtime/v1/websocket');\n\nws.onopen = () => {\n ws.send(JSON.stringify({\n type: 'auth',\n token: sessionToken, // JWT from Better Auth session\n organizationId: activeOrgId,\n }));\n};\n\nws.onmessage = (e) => {\n const msg = JSON.parse(e.data);\n if (msg.type === 'auth_success') {\n console.log('Authenticated:', {\n role: msg.role,\n roles: msg.roles,\n authMethod: msg.authMethod, // 'session'\n });\n }\n};\n```\n\n**API Key (Server/CLI):**\n```typescript\nconst ws = new WebSocket('wss://api.yourdomain.com/realtime/v1/websocket');\n\nws.onopen = () => {\n ws.send(JSON.stringify({\n type: 'auth',\n token: apiKey, // API key for machine-to-machine auth\n organizationId: orgId,\n }));\n};\n\n// authMethod will be 'api_key' on success\n```\n\n### Handling Messages\n\n```typescript\nws.onmessage = (event) => {\n const msg = JSON.parse(event.data);\n\n // Handle CRUD events\n if (msg.type === 'postgres_changes') {\n const { table, eventType, new: newRecord, old: oldRecord } = msg;\n\n if (eventType === 'INSERT') {\n addRecord(table, newRecord);\n } else if (eventType === 'UPDATE') {\n updateRecord(table, newRecord);\n } else if (eventType === 'DELETE') {\n removeRecord(table, oldRecord.id);\n }\n }\n\n // Handle custom broadcasts\n if (msg.type === 'broadcast') {\n const { event, payload } = msg;\n\n if (event === 'processing-complete') {\n refreshMaterial(payload.materialId);\n } else if (event === 'task-assigned') {\n showTaskNotification(payload.taskId);\n }\n }\n};\n```\n\n## Required Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `REALTIME_URL` | URL of the broadcast/realtime worker |\n| `ACCESS_TOKEN` | Internal service-to-service auth token |\n\nExample configuration:\n\n```toml\n# wrangler.toml\n[vars]\nREALTIME_URL = \"https://your-realtime-worker.workers.dev\"\nACCESS_TOKEN = \"your-internal-secret-token\"\n```\n\n## Architecture\n\n```\n┌─────────────┐ POST /broadcast ┌──────────────────┐\n│ API Worker │ ───────────────────────► │ Realtime Worker │\n│ (Quickback)│ │ (Durable Object)│\n└─────────────┘ └────────┬─────────┘\n │\n WebSocket│\n │\n ┌────────▼─────────┐\n │ Browser Clients │\n │ (WebSocket) │\n └──────────────────┘\n```\n\n1. **API Worker** - Your Quickback-generated API. Calls `realtime.insert()` etc. after CRUD operations.\n2. **Realtime Worker** - Separate worker with Durable Object for managing WebSocket connections.\n3. **Browser Clients** - Connect via WebSocket, subscribe to channels.\n\n## Best Practices\n\n### Broadcast After Commit\n\nAlways broadcast after the database operation succeeds:\n\n```typescript\n// Good - broadcast after successful insert\nconst [newClaim] = await db.insert(claims).values(data).returning();\nawait realtime.insert('claims', newClaim, ctx.activeOrgId!);\n\n// Bad - don't broadcast before confirming success\nawait realtime.insert('claims', data, ctx.activeOrgId!);\nawait db.insert(claims).values(data); // Could fail!\n```\n\n### Minimal Payloads\n\nOnly include necessary data in broadcasts:\n\n```typescript\n// Good - minimal payload\nawait realtime.update('materials',\n { id: record.id, status: 'completed' },\n { id: record.id, status: 'pending' },\n ctx.activeOrgId!\n);\n\n// Avoid - sending entire record with large content\nawait realtime.update('materials', fullRecord, oldRecord, ctx.activeOrgId!);\n```\n\n### Use Broadcasts for Complex Events\n\nFor events that don't map cleanly to CRUD:\n\n```typescript\n// Processing pipeline completed\nawait realtime.broadcast('pipeline-complete', {\n materialId: material.id,\n stages: ['fetch', 'extract', 'analyze'],\n claimsCreated: 5,\n quotesExtracted: 3\n}, ctx.activeOrgId!);\n```\n\n## Custom Event Namespaces\n\nFor complex applications with many custom events, you can define typed event namespaces using `defineRealtime`. This generates strongly-typed helper methods for your custom events.\n\n### Defining Event Namespaces\n\nCreate a file in `services/realtime/`:\n\n```typescript\n// services/realtime/extraction.ts\n\nexport default defineRealtime({\n name: 'extraction',\n events: ['started', 'progress', 'completed', 'failed'],\n description: 'Material extraction pipeline events',\n});\n```\n\n### Generated Helper Methods\n\nAfter compilation, the `createRealtime()` helper includes your custom namespace:\n\n```typescript\n\nexport const execute: ActionExecutor = async ({ ctx, input }) => {\n const realtime = createRealtime(ctx.env);\n\n // Type-safe custom event methods\n await realtime.extraction.started({\n materialId: input.materialId,\n }, ctx.activeOrgId!);\n\n // Progress updates\n await realtime.extraction.progress({\n materialId: input.materialId,\n percent: 50,\n stage: 'extracting',\n }, ctx.activeOrgId!);\n\n // Completion\n await realtime.extraction.completed({\n materialId: input.materialId,\n claimsExtracted: 15,\n duration: 1234,\n }, ctx.activeOrgId!);\n\n return { success: true };\n};\n```\n\n### Event Format\n\nCustom namespace events use the `broadcast` type with namespaced event names:\n\n```json\n{\n \"type\": \"broadcast\",\n \"event\": \"extraction:started\",\n \"payload\": {\n \"materialId\": \"mat_123\"\n }\n}\n```\n\n### Multiple Namespaces\n\nDefine multiple namespaces for different parts of your application:\n\n```typescript\n// services/realtime/notifications.ts\nexport default defineRealtime({\n name: 'notifications',\n events: ['new', 'read', 'dismissed'],\n description: 'User notification events',\n});\n\n// services/realtime/presence.ts\nexport default defineRealtime({\n name: 'presence',\n events: ['joined', 'left', 'typing', 'idle'],\n description: 'User presence events',\n});\n```\n\nUsage:\n```typescript\nconst realtime = createRealtime(ctx.env);\n\n// Notification events\nawait realtime.notifications.new({ ... }, ctx.activeOrgId!);\n\n// Presence events\nawait realtime.presence.joined({ userId: ctx.userId }, ctx.activeOrgId!);\n```\n\n### Namespace vs Generic Broadcast\n\n| Use Case | Approach |\n|----------|----------|\n| One-off custom event | `realtime.broadcast('event-name', payload, orgId)` |\n| Repeated event patterns | `defineRealtime` namespace |\n| Type-safe events | `defineRealtime` namespace |\n| Event discovery | `defineRealtime` (appears in generated types) |"
|
|
346
|
+
"content": "Quickback can generate realtime notification helpers for broadcasting changes to connected clients. This enables live updates via WebSocket using Cloudflare Durable Objects.\n\n## Enabling Realtime\n\nAdd `realtime` configuration to individual table definitions:\n\n```typescript\n// features/claims/claims.ts\n\nexport const claims = sqliteTable(\"claims\", {\n id: text(\"id\").primaryKey(),\n title: text(\"title\").notNull(),\n status: text(\"status\").notNull(),\n organizationId: text(\"organization_id\").notNull(),\n});\n\nexport default defineTable(claims, {\n firewall: { organization: {} },\n realtime: {\n enabled: true, // Enable realtime for this table\n onInsert: true, // Broadcast on INSERT (default: true)\n onUpdate: true, // Broadcast on UPDATE (default: true)\n onDelete: true, // Broadcast on DELETE (default: true)\n requiredRoles: [\"member\", \"admin\"], // Who receives broadcasts\n fields: [\"id\", \"title\", \"status\"], // Fields to include (optional)\n },\n // ... guards, crud\n});\n```\n\nWhen any table has realtime enabled, the compiler generates `src/lib/realtime.ts` with helper functions for sending notifications.\n\n## Generated Helper\n\nThe `createRealtime()` factory provides convenient methods for both Postgres Changes (CRUD events) and custom broadcasts:\n\n```typescript\n\nexport const execute: ActionExecutor = async ({ db, ctx, input }) => {\n const realtime = createRealtime(ctx.env);\n\n // After creating a record\n await realtime.insert('claims', newClaim, ctx.activeOrgId!);\n\n // After updating a record\n await realtime.update('claims', newClaim, oldClaim, ctx.activeOrgId!);\n\n // After deleting a record\n await realtime.delete('claims', { id: claimId }, ctx.activeOrgId!);\n\n return { success: true };\n};\n```\n\n## Event Format\n\nQuickback broadcasts events in a structured JSON format for easy client-side handling.\n\n### Insert Event\n\n```typescript\nawait realtime.insert('materials', {\n id: 'mat_123',\n title: 'Breaking News',\n status: 'pending'\n}, ctx.activeOrgId!);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"materials\",\n \"eventType\": \"INSERT\",\n \"schema\": \"public\",\n \"new\": {\n \"id\": \"mat_123\",\n \"title\": \"Breaking News\",\n \"status\": \"pending\"\n },\n \"old\": null\n}\n```\n\n### Update Event\n\n```typescript\nawait realtime.update('materials',\n { id: 'mat_123', status: 'completed' }, // new\n { id: 'mat_123', status: 'pending' }, // old\n ctx.activeOrgId!\n);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"materials\",\n \"eventType\": \"UPDATE\",\n \"schema\": \"public\",\n \"new\": { \"id\": \"mat_123\", \"status\": \"completed\" },\n \"old\": { \"id\": \"mat_123\", \"status\": \"pending\" }\n}\n```\n\n### Delete Event\n\n```typescript\nawait realtime.delete('materials',\n { id: 'mat_123' },\n ctx.activeOrgId!\n);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"materials\",\n \"eventType\": \"DELETE\",\n \"schema\": \"public\",\n \"new\": null,\n \"old\": { \"id\": \"mat_123\" }\n}\n```\n\n## Custom Broadcasts\n\nFor arbitrary events that don't map to CRUD operations:\n\n```typescript\nawait realtime.broadcast('processing-complete', {\n materialId: 'mat_123',\n claimCount: 5,\n duration: 1234\n}, ctx.activeOrgId!);\n```\n\nBroadcasts:\n```json\n{\n \"type\": \"broadcast\",\n \"event\": \"processing-complete\",\n \"payload\": {\n \"materialId\": \"mat_123\",\n \"claimCount\": 5,\n \"duration\": 1234\n }\n}\n```\n\n## User-Specific Broadcasts\n\nTarget a specific user instead of the entire organization:\n\n```typescript\n// Only this user receives the notification\nawait realtime.insert('notifications', newNotification, ctx.activeOrgId!, {\n userId: ctx.userId\n});\n\nawait realtime.broadcast('task-assigned', {\n taskId: 'task_123'\n}, ctx.activeOrgId!, {\n userId: assigneeId\n});\n```\n\n## Role-Based Filtering\n\nLimit which roles receive a broadcast using `targetRoles`:\n\n```typescript\n// Only admins and editors receive this broadcast\nawait realtime.insert('admin-actions', action, ctx.activeOrgId!, {\n targetRoles: ['admin', 'editor']\n});\n\n// Members won't see this update\nawait realtime.update('sensitive-data', newRecord, oldRecord, ctx.activeOrgId!, {\n targetRoles: ['admin']\n});\n```\n\nIf `targetRoles` is not specified, all authenticated users in the organization receive the broadcast.\n\n## Per-Role Field Masking\n\nApply different field masking based on the subscriber's role using `maskingConfig`:\n\n```typescript\nawait realtime.insert('employees', newEmployee, ctx.activeOrgId!, {\n maskingConfig: {\n ssn: { type: 'ssn', show: { roles: ['admin', 'hr'] } },\n salary: { type: 'redact', show: { roles: ['admin'] } },\n email: { type: 'email', show: { roles: ['admin', 'hr', 'manager'] } },\n },\n});\n```\n\n**How masking works:**\n- Each subscriber receives a payload masked according to their role\n- Roles in the `show.roles` array see unmasked values\n- All other roles see the masked version\n- Masking is pre-computed per-role (O(roles) not O(subscribers))\n\n**Available mask types:**\n| Type | Example Output |\n|------|----------------|\n| `email` | `j***@y*********.com` |\n| `phone` | `******4567` |\n| `ssn` | `*****6789` |\n| `creditCard` | `**** **** **** 1111` |\n| `name` | `J***` |\n| `redact` | `[REDACTED]` |\n\n### Owner-Based Masking\n\nShow unmasked data to the record owner:\n\n```typescript\nmaskingConfig: {\n ssn: {\n type: 'ssn',\n show: { roles: ['admin'], or: 'owner' } // Admin OR owner sees unmasked\n },\n}\n```\n\n## Client-Side Subscription\n\n### WebSocket Authentication\n\nThe Broadcaster supports two authentication methods:\n\n**Session Token (Browser/App):**\n```typescript\nconst ws = new WebSocket('wss://api.yourdomain.com/realtime/v1/websocket');\n\nws.onopen = () => {\n ws.send(JSON.stringify({\n type: 'auth',\n token: sessionToken, // JWT from Better Auth session\n organizationId: activeOrgId,\n }));\n};\n\nws.onmessage = (e) => {\n const msg = JSON.parse(e.data);\n if (msg.type === 'auth_success') {\n console.log('Authenticated:', {\n role: msg.role,\n roles: msg.roles,\n authMethod: msg.authMethod, // 'session'\n });\n }\n};\n```\n\n**API Key (Server/CLI):**\n```typescript\nconst ws = new WebSocket('wss://api.yourdomain.com/realtime/v1/websocket');\n\nws.onopen = () => {\n ws.send(JSON.stringify({\n type: 'auth',\n token: apiKey, // API key for machine-to-machine auth\n organizationId: orgId,\n }));\n};\n\n// authMethod will be 'api_key' on success\n```\n\n### Handling Messages\n\n```typescript\nws.onmessage = (event) => {\n const msg = JSON.parse(event.data);\n\n // Handle CRUD events\n if (msg.type === 'postgres_changes') {\n const { table, eventType, new: newRecord, old: oldRecord } = msg;\n\n if (eventType === 'INSERT') {\n addRecord(table, newRecord);\n } else if (eventType === 'UPDATE') {\n updateRecord(table, newRecord);\n } else if (eventType === 'DELETE') {\n removeRecord(table, oldRecord.id);\n }\n }\n\n // Handle custom broadcasts\n if (msg.type === 'broadcast') {\n const { event, payload } = msg;\n\n if (event === 'processing-complete') {\n refreshMaterial(payload.materialId);\n } else if (event === 'task-assigned') {\n showTaskNotification(payload.taskId);\n }\n }\n};\n```\n\n## Required Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `REALTIME_URL` | URL of the broadcast/realtime worker |\n| `ACCESS_TOKEN` | Internal service-to-service auth token |\n\nExample configuration:\n\n```toml\n# wrangler.toml\n[vars]\nREALTIME_URL = \"https://your-realtime-worker.workers.dev\"\nACCESS_TOKEN = \"your-internal-secret-token\"\n```\n\n## Architecture\n\n```\n┌─────────────┐ POST /broadcast ┌──────────────────┐\n│ API Worker │ ───────────────────────► │ Realtime Worker │\n│ (Quickback)│ │ (Durable Object)│\n└─────────────┘ └────────┬─────────┘\n │\n WebSocket│\n │\n ┌────────▼─────────┐\n │ Browser Clients │\n │ (WebSocket) │\n └──────────────────┘\n```\n\n1. **API Worker** - Your Quickback-generated API. Calls `realtime.insert()` etc. after CRUD operations.\n2. **Realtime Worker** - Separate worker with Durable Object for managing WebSocket connections.\n3. **Browser Clients** - Connect via WebSocket, subscribe to channels.\n\n## Best Practices\n\n### Broadcast After Commit\n\nAlways broadcast after the database operation succeeds:\n\n```typescript\n// Good - broadcast after successful insert\nconst [newClaim] = await db.insert(claims).values(data).returning();\nawait realtime.insert('claims', newClaim, ctx.activeOrgId!);\n\n// Bad - don't broadcast before confirming success\nawait realtime.insert('claims', data, ctx.activeOrgId!);\nawait db.insert(claims).values(data); // Could fail!\n```\n\n### Minimal Payloads\n\nOnly include necessary data in broadcasts:\n\n```typescript\n// Good - minimal payload\nawait realtime.update('materials',\n { id: record.id, status: 'completed' },\n { id: record.id, status: 'pending' },\n ctx.activeOrgId!\n);\n\n// Avoid - sending entire record with large content\nawait realtime.update('materials', fullRecord, oldRecord, ctx.activeOrgId!);\n```\n\n### Use Broadcasts for Complex Events\n\nFor events that don't map cleanly to CRUD:\n\n```typescript\n// Processing pipeline completed\nawait realtime.broadcast('pipeline-complete', {\n materialId: material.id,\n stages: ['fetch', 'extract', 'analyze'],\n claimsCreated: 5,\n quotesExtracted: 3\n}, ctx.activeOrgId!);\n```\n\n## Custom Event Namespaces\n\nFor complex applications with many custom events, you can define typed event namespaces using `defineRealtime`. This generates strongly-typed helper methods for your custom events.\n\n### Defining Event Namespaces\n\nCreate a file in `services/realtime/`:\n\n```typescript\n// services/realtime/extraction.ts\n\nexport default defineRealtime({\n name: 'extraction',\n events: ['started', 'progress', 'completed', 'failed'],\n description: 'Material extraction pipeline events',\n});\n```\n\n### Generated Helper Methods\n\nAfter compilation, the `createRealtime()` helper includes your custom namespace:\n\n```typescript\n\nexport const execute: ActionExecutor = async ({ ctx, input }) => {\n const realtime = createRealtime(ctx.env);\n\n // Type-safe custom event methods\n await realtime.extraction.started({\n materialId: input.materialId,\n }, ctx.activeOrgId!);\n\n // Progress updates\n await realtime.extraction.progress({\n materialId: input.materialId,\n percent: 50,\n stage: 'extracting',\n }, ctx.activeOrgId!);\n\n // Completion\n await realtime.extraction.completed({\n materialId: input.materialId,\n claimsExtracted: 15,\n duration: 1234,\n }, ctx.activeOrgId!);\n\n return { success: true };\n};\n```\n\n### Event Format\n\nCustom namespace events use the `broadcast` type with namespaced event names:\n\n```json\n{\n \"type\": \"broadcast\",\n \"event\": \"extraction:started\",\n \"payload\": {\n \"materialId\": \"mat_123\"\n }\n}\n```\n\n### Multiple Namespaces\n\nDefine multiple namespaces for different parts of your application:\n\n```typescript\n// services/realtime/notifications.ts\nexport default defineRealtime({\n name: 'notifications',\n events: ['new', 'read', 'dismissed'],\n description: 'User notification events',\n});\n\n// services/realtime/presence.ts\nexport default defineRealtime({\n name: 'presence',\n events: ['joined', 'left', 'typing', 'idle'],\n description: 'User presence events',\n});\n```\n\nUsage:\n```typescript\nconst realtime = createRealtime(ctx.env);\n\n// Notification events\nawait realtime.notifications.new({ ... }, ctx.activeOrgId!);\n\n// Presence events\nawait realtime.presence.joined({ userId: ctx.userId }, ctx.activeOrgId!);\n```\n\n### Namespace vs Generic Broadcast\n\n| Use Case | Approach |\n|----------|----------|\n| One-off custom event | `realtime.broadcast('event-name', payload, orgId)` |\n| Repeated event patterns | `defineRealtime` namespace |\n| Type-safe events | `defineRealtime` namespace |\n| Event discovery | `defineRealtime` (appears in generated types) |"
|
|
311
347
|
},
|
|
312
348
|
"stack/realtime": {
|
|
313
349
|
"title": "Realtime",
|
|
@@ -315,7 +351,7 @@ export const DOCS = {
|
|
|
315
351
|
},
|
|
316
352
|
"stack/realtime/using-realtime": {
|
|
317
353
|
"title": "Using Realtime",
|
|
318
|
-
"content": "This page covers connecting to the Quickback realtime system from client applications — authentication, subscribing to events, and handling messages.\n\n## Connecting\n\nOpen a WebSocket connection to the realtime worker:\n\n```typescript\nconst ws = new WebSocket(\"wss://api.yourdomain.com/realtime/v1/websocket\");\n```\n\n## Authentication\n\nAfter connecting, send an auth message to authenticate. Two methods are supported.\n\n### Session Token (Browser/App)\n\n```typescript\nws.onopen = () => {\n ws.send(JSON.stringify({\n type: \"auth\",\n token: sessionToken, // JWT from Better Auth session\n organizationId: activeOrgId,\n }));\n};\n```\n\n### API Key (Server/CLI)\n\n```typescript\nws.onopen = () => {\n ws.send(JSON.stringify({\n type: \"auth\",\n token: apiKey, // API key for machine-to-machine auth\n organizationId: orgId,\n }));\n};\n```\n\n### Auth Response\n\nOn success:\n\n```json\n{\n \"type\": \"auth_success\",\n \"organizationId\": \"org_123\",\n \"userId\": \"user_456\",\n \"role\": \"admin\",\n \"roles\": [\"admin\", \"member\"],\n \"authMethod\": \"session\"\n}\n```\n\nOn failure, the connection is closed with an error message.\n\n## Handling Messages\n\n### CRUD Events\n\nCRUD events use the `postgres_changes` type:\n\n```typescript\nws.onmessage = (event) => {\n const msg = JSON.parse(event.data);\n\n if (msg.type === \"postgres_changes\") {\n const { table, eventType, new: newRecord, old: oldRecord } = msg;\n\n switch (eventType) {\n case \"INSERT\":\n addRecord(table, newRecord);\n break;\n case \"UPDATE\":\n updateRecord(table, newRecord);\n break;\n case \"DELETE\":\n removeRecord(table, oldRecord.id);\n break;\n }\n }\n};\n```\n\n**Event payload:**\n\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"claims\",\n \"schema\": \"public\",\n \"eventType\": \"INSERT\",\n \"new\": { \"id\": \"clm_123\", \"title\": \"Breaking News\", \"status\": \"pending\" },\n \"old\": null\n}\n```\n\nFor UPDATE events, both `new` and `old` are populated. For DELETE events, only `old` is populated.\n\n### Custom Broadcasts\n\nCustom events use the `broadcast` type:\n\n```typescript\nif (msg.type === \"broadcast\") {\n const { event, payload } = msg;\n\n if (event === \"processing-complete\") {\n refreshMaterial(payload.materialId);\n } else if (event === \"extraction:progress\") {\n updateProgressBar(payload.percent);\n }\n}\n```\n\n**Event payload:**\n\n```json\n{\n \"type\": \"broadcast\",\n \"event\": \"processing-complete\",\n \"payload\": {\n \"materialId\": \"mat_123\",\n \"claimCount\": 5,\n \"duration\": 1234\n }\n}\n```\n\nCustom namespaces (from `defineRealtime()`) use the format `namespace:event` — e.g., `extraction:started`, `extraction:progress`.\n\n## Security\n\n### Role-Based Filtering\n\nEvents are only delivered to users whose role matches the broadcast's `targetRoles`. If a table's realtime config specifies `requiredRoles: [\"admin\", \"member\"]`, only users with those roles receive the events.\n\n### Per-Role Masking\n\nField values are masked according to the subscriber's role. For example, with this masking config:\n\n```typescript\nmasking: {\n ssn: { type: \"ssn\", show: { roles: [\"admin\"] } },\n}\n```\n\n- **Admin sees:** `{ ssn: \"123-45-6789\" }`\n- **Member sees:** `{ ssn: \"
|
|
354
|
+
"content": "This page covers connecting to the Quickback realtime system from client applications — authentication, subscribing to events, and handling messages.\n\n## Connecting\n\nOpen a WebSocket connection to the realtime worker:\n\n```typescript\nconst ws = new WebSocket(\"wss://api.yourdomain.com/realtime/v1/websocket\");\n```\n\n## Authentication\n\nAfter connecting, send an auth message to authenticate. Two methods are supported.\n\n### Session Token (Browser/App)\n\n```typescript\nws.onopen = () => {\n ws.send(JSON.stringify({\n type: \"auth\",\n token: sessionToken, // JWT from Better Auth session\n organizationId: activeOrgId,\n }));\n};\n```\n\n### API Key (Server/CLI)\n\n```typescript\nws.onopen = () => {\n ws.send(JSON.stringify({\n type: \"auth\",\n token: apiKey, // API key for machine-to-machine auth\n organizationId: orgId,\n }));\n};\n```\n\n### Auth Response\n\nOn success:\n\n```json\n{\n \"type\": \"auth_success\",\n \"organizationId\": \"org_123\",\n \"userId\": \"user_456\",\n \"role\": \"admin\",\n \"roles\": [\"admin\", \"member\"],\n \"authMethod\": \"session\"\n}\n```\n\nOn failure, the connection is closed with an error message.\n\n## Handling Messages\n\n### CRUD Events\n\nCRUD events use the `postgres_changes` type:\n\n```typescript\nws.onmessage = (event) => {\n const msg = JSON.parse(event.data);\n\n if (msg.type === \"postgres_changes\") {\n const { table, eventType, new: newRecord, old: oldRecord } = msg;\n\n switch (eventType) {\n case \"INSERT\":\n addRecord(table, newRecord);\n break;\n case \"UPDATE\":\n updateRecord(table, newRecord);\n break;\n case \"DELETE\":\n removeRecord(table, oldRecord.id);\n break;\n }\n }\n};\n```\n\n**Event payload:**\n\n```json\n{\n \"type\": \"postgres_changes\",\n \"table\": \"claims\",\n \"schema\": \"public\",\n \"eventType\": \"INSERT\",\n \"new\": { \"id\": \"clm_123\", \"title\": \"Breaking News\", \"status\": \"pending\" },\n \"old\": null\n}\n```\n\nFor UPDATE events, both `new` and `old` are populated. For DELETE events, only `old` is populated.\n\n### Custom Broadcasts\n\nCustom events use the `broadcast` type:\n\n```typescript\nif (msg.type === \"broadcast\") {\n const { event, payload } = msg;\n\n if (event === \"processing-complete\") {\n refreshMaterial(payload.materialId);\n } else if (event === \"extraction:progress\") {\n updateProgressBar(payload.percent);\n }\n}\n```\n\n**Event payload:**\n\n```json\n{\n \"type\": \"broadcast\",\n \"event\": \"processing-complete\",\n \"payload\": {\n \"materialId\": \"mat_123\",\n \"claimCount\": 5,\n \"duration\": 1234\n }\n}\n```\n\nCustom namespaces (from `defineRealtime()`) use the format `namespace:event` — e.g., `extraction:started`, `extraction:progress`.\n\n## Security\n\n### Role-Based Filtering\n\nEvents are only delivered to users whose role matches the broadcast's `targetRoles`. If a table's realtime config specifies `requiredRoles: [\"admin\", \"member\"]`, only users with those roles receive the events.\n\n### Per-Role Masking\n\nField values are masked according to the subscriber's role. For example, with this masking config:\n\n```typescript\nmasking: {\n ssn: { type: \"ssn\", show: { roles: [\"admin\"] } },\n}\n```\n\n- **Admin sees:** `{ ssn: \"123-45-6789\" }`\n- **Member sees:** `{ ssn: \"*****6789\" }`\n\nMasking is pre-computed per-role (O(roles), not O(subscribers)) for efficiency.\n\n### User-Specific Events\n\nEvents can target a specific user within an organization. Only that user receives the broadcast — all other connections in the org are skipped.\n\n### Organization Isolation\n\nEach organization has its own Durable Object instance. Users can only subscribe to events in organizations they belong to, enforced during authentication.\n\n## Reconnection\n\nWebSocket connections can drop due to network issues. Implement reconnection logic in your client:\n\n```typescript\nfunction connect() {\n const ws = new WebSocket(\"wss://api.yourdomain.com/realtime/v1/websocket\");\n\n ws.onopen = () => {\n ws.send(JSON.stringify({\n type: \"auth\",\n token: sessionToken,\n organizationId: activeOrgId,\n }));\n };\n\n ws.onclose = () => {\n // Reconnect after delay\n setTimeout(connect, 2000);\n };\n\n ws.onmessage = (event) => {\n const msg = JSON.parse(event.data);\n handleMessage(msg);\n };\n\n return ws;\n}\n```\n\n## Required Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `REALTIME_URL` | URL of the broadcast/realtime worker |\n| `ACCESS_TOKEN` | Internal service-to-service auth token |\n\n```toml\n# wrangler.toml\n[vars]\nREALTIME_URL = \"https://my-app-broadcast.workers.dev\"\nACCESS_TOKEN = \"your-internal-secret-token\"\n```\n\n## Cloudflare Only\n\nRealtime requires Cloudflare Durable Objects and is only available with the Cloudflare runtime.\n\n## See Also\n\n- [Durable Objects Setup](/stack/realtime/durable-objects) — Configuration, event formats, masking, and custom namespaces\n- [Masking](/compiler/definitions/masking) — Field masking configuration"
|
|
319
355
|
},
|
|
320
356
|
"stack/storage": {
|
|
321
357
|
"title": "Storage",
|
|
@@ -339,7 +375,7 @@ export const DOCS = {
|
|
|
339
375
|
},
|
|
340
376
|
"stack/vector/embeddings": {
|
|
341
377
|
"title": "Automatic Embeddings",
|
|
342
|
-
"content": "Quickback can automatically generate embeddings for your data using Cloudflare Queues and Workers AI. When configured, INSERT and UPDATE operations automatically enqueue embedding jobs that are processed asynchronously.\n\n## Enabling Embeddings\n\nAdd an `embeddings` configuration to your resource definition:\n\n```typescript\n// claims/resource.ts\n\nexport default defineResource(claims, {\n firewall: { organization: {} },\n\n embeddings: {\n fields: ['content'], // Fields to concatenate and embed\n model: '@cf/baai/bge-base-en-v1.5', // Embedding model (optional)\n onInsert: true, // Auto-embed on create (default: true)\n onUpdate: ['content'], // Re-embed when these fields change\n embeddingColumn: 'embedding', // Column to store embedding\n metadata: ['storyId'], // Metadata for Vectorize index\n },\n\n crud: {\n create: { access: { roles: ['member'] } },\n update: { access: { roles: ['member'] } },\n },\n});\n```\n\n## Configuration Options\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `fields` | `string[]` | **Required** | Fields to concatenate and embed |\n| `model` | `string` | `'@cf/baai/bge-base-en-v1.5'` | Workers AI embedding model |\n| `onInsert` | `boolean` | `true` | Embed on INSERT operations |\n| `onUpdate` | `boolean \\| string[]` | `true` | Embed on UPDATE; array limits to specific fields |\n| `embeddingColumn` | `string` | `'embedding'` | Column to store the embedding vector |\n| `metadata` | `string[]` | `[]` | Fields to include in Vectorize metadata |\n\n### onUpdate Options\n\n```typescript\n// Always re-embed on any update\nonUpdate: true\n\n// Never re-embed on update\nonUpdate: false\n\n// Only re-embed when specific fields change\nonUpdate: ['content', 'title']\n```\n\n## How It Works\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│ Main API Worker │\n│ │\n│ ┌─────────────────────┐ ┌────────────────────┐ │\n│ │ POST /claims │ │ Queue Consumer │ │\n│ │ │ │ │ │\n│ │ 1. Auth middleware │ │ 1. Workers AI │ │\n│ │ 2. Firewall │ │ embed() │ │\n│ │ 3. Guards │ │ 2. D1 update() │ │\n│ │ 4. Insert to D1 │ │ 3. Vectorize │ │\n│ │ 5. Enqueue job ─────┼───▶│ upsert() │ │\n│ └─────────────────────┘ └────────────────────┘ │\n│ ▲ │\n│ ┌─────────────────┴──────────────────┐ │\n│ │ EMBEDDINGS_QUEUE │ │\n│ └────────────────────────────────────┘ │\n└─────────────────────────────────────────────────────────────────────┘\n```\n\n1. **API Request** - POST/PATCH arrives and passes through auth, firewall, guards\n2. **Database Insert** - Record is created/updated in D1\n3. **Enqueue Job** - Embedding job is sent to Cloudflare Queue\n4. **Queue Consumer** - Processes job asynchronously:\n - Calls Workers AI to generate embedding\n - Updates D1 with embedding vector\n - Optionally upserts to Vectorize index\n\n## Security Model\n\n**Security is enforced at enqueue time, not consume time:**\n\n- Jobs are only enqueued after passing all security checks (auth, firewall, guards)\n- The queue consumer is an internal process that executes pre-validated jobs\n- If a user can't create a claim, they can't trigger an embedding job\n\n## Generic Embeddings API\n\nIn addition to automatic embeddings on CRUD operations, Quickback generates a generic embeddings API endpoint that allows you to trigger embeddings on arbitrary content.\n\n### POST /api/v1/embeddings\n\nGenerate an embedding for any text content:\n\n```bash\ncurl -X POST https://your-api.workers.dev/api/v1/embeddings \\\n -H \"Content-Type: application/json\" \\\n -H \"Cookie: better-auth.session_token=...\" \\\n -d '{\n \"content\": \"Text to embed\",\n \"model\": \"@cf/baai/bge-base-en-v1.5\",\n \"table\": \"claims\",\n \"id\": \"clm_123\"\n }'\n```\n\n#### Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `content` | `string` | Yes | Text to generate embedding for |\n| `model` | `string` | No | Embedding model (defaults to table config or `@cf/baai/bge-base-en-v1.5`) |\n| `table` | `string` | No | Table to store embedding back to |\n| `id` | `string` | Conditional | Record ID to update (required if `table` is specified) |\n\n#### Response\n\n```json\n{\n \"queued\": true,\n \"jobId\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"table\": \"claims\",\n \"id\": \"clm_123\",\n \"model\": \"@cf/baai/bge-base-en-v1.5\"\n}\n```\n\n### GET /api/v1/embeddings/tables\n\nList tables that have embeddings configured:\n\n```bash\ncurl https://your-api.workers.dev/api/v1/embeddings/tables \\\n -H \"Cookie: better-auth.session_token=...\"\n```\n\nResponse:\n\n```json\n{\n \"tables\": [\n {\n \"name\": \"claims\",\n \"embeddingColumn\": \"embedding\",\n \"model\": \"@cf/baai/bge-base-en-v1.5\"\n }\n ]\n}\n```\n\n### Authentication & Authorization\n\nThe generic embeddings API requires authentication and uses the `activeOrgId` from the user's context to enforce organization-level isolation. Embedding jobs are scoped to the user's current organization.\n\n### Use Cases\n\nThe generic embeddings API is useful for:\n\n- **Batch embedding**: Embed content without going through CRUD routes\n- **Re-embedding**: Force re-generation of embeddings for existing records\n- **Preview embeddings**: Test embedding generation before persisting\n- **External content**: Embed content that doesn't fit your defined schemas\n\n## Generated Files\n\nWhen embeddings are configured, the compiler generates:\n\n| File | Purpose |\n|------|---------|\n| `src/queue-consumer.ts` | Queue consumer handler for processing embedding jobs |\n| `src/routes/embeddings.ts` | Generic embeddings API routes |\n| `wrangler.toml` | Queue producer/consumer bindings, AI binding |\n| `src/env.d.ts` | `EMBEDDINGS_QUEUE`, `AI` types |\n| `src/index.ts` | Exports `queue` handler, mounts `/api/v1/embeddings` |\n\n### wrangler.toml additions\n\n```toml\n# Embeddings Queue\n[[queues.producers]]\nqueue = \"your-app-embeddings-queue\"\nbinding = \"EMBEDDINGS_QUEUE\"\n\n[[queues.consumers]]\nqueue = \"your-app-embeddings-queue\"\nmax_batch_size = 10\nmax_batch_timeout = 30\nmax_retries = 3\n\n# Workers AI\n[ai]\nbinding = \"AI\"\n```\n\n## Multiple Fields\n\nEmbed multiple fields by concatenating them:\n\n```typescript\nembeddings: {\n fields: ['title', 'content', 'summary'], // Joined with spaces\n // ...\n}\n```\n\nGenerated embedding text: `\"${title} ${content} ${summary}\"`\n\n## Vectorize Integration\n\nIf you have a Vectorize index configured, embeddings are automatically upserted:\n\n```typescript\n// quickback.config.ts\nproviders: {\n database: {\n config: {\n vectorizeIndexName: 'claims-embeddings', // Your Vectorize index\n vectorizeBinding: 'VECTORIZE',\n },\n },\n},\n```\n\nThe queue consumer will:\n1. Generate the embedding via Workers AI\n2. Store the vector in D1 (JSON string)\n3. Upsert to Vectorize with metadata\n\n### Vectorize Metadata\n\nInclude fields in Vectorize metadata for filtering:\n\n```typescript\nembeddings: {\n fields: ['content'],\n metadata: ['storyId', 'organizationId', 'claimType'],\n}\n```\n\nEnables queries like:\n```typescript\nconst results = await env.VECTORIZE.query(vector, {\n topK: 10,\n filter: { storyId: 'story_123' }\n});\n```\n\n## Schema Requirements\n\nYour schema must include the embedding column:\n\n```typescript\n// claims/schema.ts\n\nexport const claims = sqliteTable(\"claims\", {\n id: text(\"id\").primaryKey(),\n content: text(\"content\").notNull(),\n storyId: text(\"story_id\"),\n organizationId: text(\"organization_id\").notNull(),\n\n // Embedding column - stores JSON array of floats\n embedding: text(\"embedding\"),\n});\n```\n\n## Supported Models\n\nAny Workers AI embedding model can be used:\n\n| Model | Dimensions | Notes |\n|-------|------------|-------|\n| `@cf/baai/bge-base-en-v1.5` | 768 | Default, good general-purpose |\n| `@cf/baai/bge-small-en-v1.5` | 384 | Faster, smaller |\n| `@cf/baai/bge-large-en-v1.5` | 1024 | Higher quality |\n\nSee [Workers AI Models](https://developers.cloudflare.com/workers-ai/models/) for the full list.\n\n## Deployment\n\nAfter compiling with embeddings:\n\n1. **Create the queue:**\n ```bash\n wrangler queues create your-app-embeddings-queue\n ```\n\n2. **Deploy the worker:**\n ```bash\n wrangler deploy\n ```\n\nThe single worker handles both HTTP requests and queue consumption.\n\n## Monitoring\n\nQueue metrics are available in the Cloudflare dashboard:\n- Messages enqueued\n- Messages processed\n- Retry count\n- Consumer lag\n\nCheck queue health:\n```bash\nwrangler queues list\nwrangler queues consumer your-app-embeddings-queue\n```\n\n## Error Handling\n\nFailed jobs are automatically retried (up to `max_retries`):\n\n```typescript\n// In queue consumer\ntry {\n const embedding = await env.AI.run(job.model, { text: job.content });\n // ...\n message.ack();\n} catch (error) {\n console.error('[Queue] Embedding job failed:', error);\n message.retry(); // Will retry up to 3 times\n}\n```\n\nAfter max retries, the message is dead-lettered (if configured) or dropped.\n\n## Similarity Search Service\n\nFor applications that need typed similarity search with classification, you can define embedding search configurations using `defineEmbedding`. This generates a service layer with typed search functions.\n\n### Defining Search Configurations\n\nCreate a file in `services/embeddings/`:\n\n```typescript\n// services/embeddings/claim-similarity.ts\n\nexport default defineEmbedding({\n name: 'claim-similarity',\n description: 'Find similar claims with classification',\n\n // Source configuration\n source: 'claims', // Table name\n vectorIndex: 'VECTORIZE', // Binding name\n model: '@cf/baai/bge-base-en-v1.5',\n\n // Search configuration\n search: {\n threshold: 0.60, // Minimum similarity (default: 0.60)\n limit: 10, // Max results (default: 10)\n classify: {\n DUPLICATE: 0.90, // Score >= 0.90 = DUPLICATE\n CONFIRMS: 0.85, // Score >= 0.85 = CONFIRMS\n RELATED: 0.75, // Score >= 0.75 = RELATED\n },\n filters: ['storyId', 'organizationId'], // Filterable fields\n },\n\n // Generation triggers (beyond CRUD)\n triggers: {\n onQueueMessage: 'embed_claim', // Listen for queue messages\n },\n});\n```\n\n### Generated Service Layer\n\nAfter compilation, a `createEmbeddings()` helper is generated in `src/lib/embeddings.ts`:\n\n```typescript\n\nexport const execute: ActionExecutor = async ({ ctx, input }) => {\n const embeddings = createEmbeddings(ctx.env);\n\n // Search with automatic classification\n const similar = await embeddings.claimSimilarity.search(\n 'Police arrested three suspects in downtown robbery',\n {\n storyId: 'story_123',\n limit: 5,\n threshold: 0.70,\n }\n );\n\n // Returns: [{ id, score: 0.87, classification: 'CONFIRMS', metadata }]\n for (const match of similar) {\n console.log(`${match.classification}: ${match.id} (${match.score})`);\n }\n\n return { similar };\n};\n```\n\n### Classification Thresholds\n\nResults are automatically classified based on similarity score:\n\n| Classification | Default Threshold | Meaning |\n|----------------|-------------------|---------|\n| `DUPLICATE` | >= 0.90 | Near-identical content |\n| `CONFIRMS` | >= 0.85 | Strongly supports same claim |\n| `RELATED` | >= 0.75 | Topically related |\n| `NEW` | < 0.75 | No significant match |\n\nCustomize thresholds per use case:\n\n```typescript\nsearch: {\n classify: {\n DUPLICATE: 0.95, // Stricter duplicate detection\n CONFIRMS: 0.88,\n RELATED: 0.70, // Broader \"related\" category\n },\n}\n```\n\n### Gray Zone Detection\n\nFor cases where automatic classification isn't sufficient, use gray zone detection to get matches that need semantic evaluation:\n\n```typescript\nconst results = await embeddings.claimSimilarity.findWithGrayZone(\n 'Some claim text',\n { min: 0.60, max: 0.85 }\n);\n\n// Returns structured results:\n// {\n// high_confidence: [...], // Score >= 0.85 (auto-classified)\n// gray_zone: [...] // 0.60 <= score < 0.85 (needs review)\n// }\n\n// Process high confidence matches automatically\nfor (const match of results.high_confidence) {\n await markAsDuplicate(match.id);\n}\n\n// Queue gray zone for semantic review\nfor (const match of results.gray_zone) {\n await queueForReview(match.id, match.score);\n}\n```\n\n### Generate Embeddings Directly\n\nGenerate embeddings without searching:\n\n```typescript\nconst embeddings = createEmbeddings(ctx.env);\n\n// Get raw embedding vector\nconst vector = await embeddings.claimSimilarity.embed(\n 'Text to embed'\n);\n// Returns: number[] (768 dimensions for bge-base)\n```\n\n### Multiple Search Configurations\n\nDefine different configurations for different use cases:\n\n```typescript\n// services/embeddings/story-similarity.ts\nexport default defineEmbedding({\n name: 'story-similarity',\n source: 'stories',\n search: {\n threshold: 0.65,\n limit: 20,\n classify: {\n DUPLICATE: 0.92,\n CONFIRMS: 0.80,\n RELATED: 0.65,\n },\n filters: ['streamId'],\n },\n});\n\n// services/embeddings/material-similarity.ts\nexport default defineEmbedding({\n name: 'material-similarity',\n source: 'materials',\n search: {\n threshold: 0.70,\n limit: 5,\n classify: {\n DUPLICATE: 0.95,\n CONFIRMS: 0.90,\n RELATED: 0.80,\n },\n filters: ['sourceType', 'organizationId'],\n },\n});\n```\n\nUsage:\n```typescript\nconst embeddings = createEmbeddings(ctx.env);\n\n// Different search behaviors for different content types\nconst similarClaims = await embeddings.claimSimilarity.search(text, opts);\nconst similarStories = await embeddings.storySimilarity.search(text, opts);\nconst similarMaterials = await embeddings.materialSimilarity.search(text, opts);\n```\n\n### Table-Level vs Service-Level\n\n| Feature | Table-level (`defineTable`) | Service-level (`defineEmbedding`) |\n|---------|----------------------------|----------------------------------|\n| Auto-embed on INSERT | ✅ | ❌ |\n| Auto-embed on UPDATE | ✅ | ❌ |\n| Custom search functions | ❌ | ✅ |\n| Classification thresholds | ❌ | ✅ |\n| Gray zone detection | ❌ | ✅ |\n| Filterable searches | ❌ | ✅ |\n| Queue message triggers | ❌ | ✅ |\n\n**Use both together:**\n- `defineTable` with `embeddings` config for automatic embedding generation\n- `defineEmbedding` for typed search functions with classification"
|
|
378
|
+
"content": "Quickback can automatically generate embeddings for your data using Cloudflare Queues and Workers AI. When configured, INSERT and UPDATE operations automatically enqueue embedding jobs that are processed asynchronously.\n\n## Enabling Embeddings\n\nAdd an `embeddings` configuration to your resource definition:\n\n```typescript\n// claims/resource.ts\n\nexport default defineResource(claims, {\n firewall: { organization: {} },\n\n embeddings: {\n fields: ['content'], // Fields to concatenate and embed\n model: '@cf/baai/bge-base-en-v1.5', // Embedding model (optional)\n onInsert: true, // Auto-embed on create (default: true)\n onUpdate: ['content'], // Re-embed when these fields change\n embeddingColumn: 'embedding', // Column to store embedding\n metadata: ['storyId'], // Metadata for Vectorize index\n },\n\n crud: {\n create: { access: { roles: ['member'] } },\n update: { access: { roles: ['member'] } },\n },\n});\n```\n\n## Configuration Options\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `fields` | `string[]` | **Required** | Fields to concatenate and embed |\n| `model` | `string` | `'@cf/baai/bge-base-en-v1.5'` | Workers AI embedding model |\n| `onInsert` | `boolean` | `true` | Embed on INSERT operations |\n| `onUpdate` | `boolean \\| string[]` | `true` | Embed on UPDATE; array limits to specific fields |\n| `embeddingColumn` | `string` | `'embedding'` | Column to store the embedding vector |\n| `separator` | `string` | `' '` | Separator for joining multiple fields |\n| `metadata` | `string[]` | `[]` | Fields to include in Vectorize metadata |\n\n### onUpdate Options\n\n```typescript\n// Always re-embed on any update\nonUpdate: true\n\n// Never re-embed on update\nonUpdate: false\n\n// Only re-embed when specific fields change\nonUpdate: ['content', 'title']\n```\n\n## How It Works\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│ Main API Worker │\n│ │\n│ ┌─────────────────────┐ ┌────────────────────┐ │\n│ │ POST /claims │ │ Queue Consumer │ │\n│ │ │ │ │ │\n│ │ 1. Auth middleware │ │ 1. Workers AI │ │\n│ │ 2. Firewall │ │ embed() │ │\n│ │ 3. Guards │ │ 2. D1 update() │ │\n│ │ 4. Insert to D1 │ │ 3. Vectorize │ │\n│ │ 5. Enqueue job ─────┼───▶│ upsert() │ │\n│ └─────────────────────┘ └────────────────────┘ │\n│ ▲ │\n│ ┌─────────────────┴──────────────────┐ │\n│ │ EMBEDDINGS_QUEUE │ │\n│ └────────────────────────────────────┘ │\n└─────────────────────────────────────────────────────────────────────┘\n```\n\n1. **API Request** - POST/PATCH arrives and passes through auth, firewall, guards\n2. **Database Insert** - Record is created/updated in D1\n3. **Enqueue Job** - Embedding job is sent to Cloudflare Queue\n4. **Queue Consumer** - Processes job asynchronously:\n - Calls Workers AI to generate embedding\n - Updates D1 with embedding vector\n - Optionally upserts to Vectorize index\n\n## Security Model\n\n**Security is enforced at enqueue time, not consume time:**\n\n- Jobs are only enqueued after passing all security checks (auth, firewall, guards)\n- The queue consumer is an internal process that executes pre-validated jobs\n- If a user can't create a claim, they can't trigger an embedding job\n\n## Generic Embeddings API\n\nIn addition to automatic embeddings on CRUD operations, Quickback generates a generic embeddings API endpoint that allows you to trigger embeddings on arbitrary content.\n\n### POST /api/v1/embeddings\n\nGenerate an embedding for any text content:\n\n```bash\ncurl -X POST https://your-api.workers.dev/api/v1/embeddings \\\n -H \"Content-Type: application/json\" \\\n -H \"Cookie: better-auth.session_token=...\" \\\n -d '{\n \"content\": \"Text to embed\",\n \"model\": \"@cf/baai/bge-base-en-v1.5\",\n \"table\": \"claims\",\n \"id\": \"clm_123\"\n }'\n```\n\n#### Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| `content` | `string` | Yes | Text to generate embedding for |\n| `model` | `string` | No | Embedding model (defaults to table config or `@cf/baai/bge-base-en-v1.5`) |\n| `table` | `string` | No | Table to store embedding back to |\n| `id` | `string` | Conditional | Record ID to update (required if `table` is specified) |\n\n#### Response\n\n```json\n{\n \"queued\": true,\n \"jobId\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"table\": \"claims\",\n \"id\": \"clm_123\",\n \"model\": \"@cf/baai/bge-base-en-v1.5\"\n}\n```\n\n### GET /api/v1/embeddings/tables\n\nList tables that have embeddings configured:\n\n```bash\ncurl https://your-api.workers.dev/api/v1/embeddings/tables \\\n -H \"Cookie: better-auth.session_token=...\"\n```\n\nResponse:\n\n```json\n{\n \"tables\": [\n {\n \"name\": \"claims\",\n \"embeddingColumn\": \"embedding\",\n \"model\": \"@cf/baai/bge-base-en-v1.5\"\n }\n ]\n}\n```\n\n### Authentication & Authorization\n\nThe generic embeddings API requires authentication and uses the `activeOrgId` from the user's context to enforce organization-level isolation. Embedding jobs are scoped to the user's current organization.\n\n### Use Cases\n\nThe generic embeddings API is useful for:\n\n- **Batch embedding**: Embed content without going through CRUD routes\n- **Re-embedding**: Force re-generation of embeddings for existing records\n- **Preview embeddings**: Test embedding generation before persisting\n- **External content**: Embed content that doesn't fit your defined schemas\n\n## Generated Files\n\nWhen embeddings are configured, the compiler generates:\n\n| File | Purpose |\n|------|---------|\n| `src/queue-consumer.ts` | Queue consumer handler for processing embedding jobs |\n| `src/routes/embeddings.ts` | Generic embeddings API routes |\n| `wrangler.toml` | Queue producer/consumer bindings, AI binding |\n| `src/env.d.ts` | `EMBEDDINGS_QUEUE`, `AI` types |\n| `src/index.ts` | Exports `queue` handler, mounts `/api/v1/embeddings` |\n\n### wrangler.toml additions\n\n```toml\n# Embeddings Queue\n[[queues.producers]]\nqueue = \"your-app-embeddings-queue\"\nbinding = \"EMBEDDINGS_QUEUE\"\n\n[[queues.consumers]]\nqueue = \"your-app-embeddings-queue\"\nmax_batch_size = 10\nmax_batch_timeout = 30\nmax_retries = 3\n\n# Workers AI\n[ai]\nbinding = \"AI\"\n```\n\n## Multiple Fields\n\nEmbed multiple fields by concatenating them:\n\n```typescript\nembeddings: {\n fields: ['title', 'content', 'summary'], // Joined with spaces by default\n // ...\n}\n```\n\nGenerated embedding text: `\"${title} ${content} ${summary}\"`\n\n### Custom Separator\n\nUse `separator` to control how fields are joined. This is useful for sentence boundary detection:\n\n```typescript\nembeddings: {\n fields: ['title', 'summary'],\n separator: '. ', // Join with period + space\n // ...\n}\n```\n\nGenerated code: `[result[0].title, result[0].summary].filter(Boolean).join('. ')`\n\nThe `filter(Boolean)` ensures null or empty fields are excluded cleanly — no trailing separator when a field is absent.\n\n## Vectorize Integration\n\nIf you have a Vectorize index configured, embeddings are automatically upserted:\n\n```typescript\n// quickback.config.ts\nproviders: {\n database: {\n config: {\n vectorizeIndexName: 'claims-embeddings', // Your Vectorize index\n vectorizeBinding: 'VECTORIZE',\n },\n },\n},\n```\n\nThe queue consumer will:\n1. Generate the embedding via Workers AI\n2. Store the vector in D1 (JSON string)\n3. Upsert to Vectorize with metadata\n\n### Vectorize Metadata\n\nInclude fields in Vectorize metadata for filtering:\n\n```typescript\nembeddings: {\n fields: ['content'],\n metadata: ['storyId', 'organizationId', 'claimType'],\n}\n```\n\nEnables queries like:\n```typescript\nconst results = await env.VECTORIZE.query(vector, {\n topK: 10,\n filter: { storyId: 'story_123' }\n});\n```\n\n## Schema Requirements\n\nYour schema must include the embedding column:\n\n```typescript\n// claims/schema.ts\n\nexport const claims = sqliteTable(\"claims\", {\n id: text(\"id\").primaryKey(),\n content: text(\"content\").notNull(),\n storyId: text(\"story_id\"),\n organizationId: text(\"organization_id\").notNull(),\n\n // Embedding column - stores JSON array of floats\n embedding: text(\"embedding\"),\n});\n```\n\n## Supported Models\n\nAny Workers AI embedding model can be used:\n\n| Model | Dimensions | Notes |\n|-------|------------|-------|\n| `@cf/baai/bge-base-en-v1.5` | 768 | Default, good general-purpose |\n| `@cf/baai/bge-small-en-v1.5` | 384 | Faster, smaller |\n| `@cf/baai/bge-large-en-v1.5` | 1024 | Higher quality |\n\nSee [Workers AI Models](https://developers.cloudflare.com/workers-ai/models/) for the full list.\n\n## Deployment\n\nAfter compiling with embeddings:\n\n1. **Create the queue:**\n ```bash\n wrangler queues create your-app-embeddings-queue\n ```\n\n2. **Deploy the worker:**\n ```bash\n wrangler deploy\n ```\n\nThe single worker handles both HTTP requests and queue consumption.\n\n## Monitoring\n\nQueue metrics are available in the Cloudflare dashboard:\n- Messages enqueued\n- Messages processed\n- Retry count\n- Consumer lag\n\nCheck queue health:\n```bash\nwrangler queues list\nwrangler queues consumer your-app-embeddings-queue\n```\n\n## Error Handling\n\nFailed jobs are automatically retried (up to `max_retries`):\n\n```typescript\n// In queue consumer\ntry {\n const embedding = await env.AI.run(job.model, { text: job.content });\n // ...\n message.ack();\n} catch (error) {\n console.error('[Queue] Embedding job failed:', error);\n message.retry(); // Will retry up to 3 times\n}\n```\n\nAfter max retries, the message is dead-lettered (if configured) or dropped.\n\n## Similarity Search Service\n\nFor applications that need typed similarity search with classification, you can define embedding search configurations using `defineEmbedding`. This generates a service layer with typed search functions.\n\n### Defining Search Configurations\n\nCreate a file in `services/embeddings/`:\n\n```typescript\n// services/embeddings/claim-similarity.ts\n\nexport default defineEmbedding({\n name: 'claim-similarity',\n description: 'Find similar claims with classification',\n\n // Source configuration\n source: 'claims', // Table name\n vectorIndex: 'VECTORIZE', // Binding name\n model: '@cf/baai/bge-base-en-v1.5',\n\n // Search configuration\n search: {\n threshold: 0.60, // Minimum similarity (default: 0.60)\n limit: 10, // Max results (default: 10)\n classify: {\n DUPLICATE: 0.90, // Score >= 0.90 = DUPLICATE\n CONFIRMS: 0.85, // Score >= 0.85 = CONFIRMS\n RELATED: 0.75, // Score >= 0.75 = RELATED\n },\n filters: ['storyId', 'organizationId'], // Filterable fields\n },\n\n // Generation triggers (beyond CRUD)\n triggers: {\n onQueueMessage: 'embed_claim', // Listen for queue messages\n },\n});\n```\n\n### Generated Service Layer\n\nAfter compilation, a `createEmbeddings()` helper is generated in `src/lib/embeddings.ts`:\n\n```typescript\n\nexport const execute: ActionExecutor = async ({ ctx, input }) => {\n const embeddings = createEmbeddings(ctx.env);\n\n // Search with automatic classification\n const similar = await embeddings.claimSimilarity.search(\n 'Police arrested three suspects in downtown robbery',\n {\n storyId: 'story_123',\n limit: 5,\n threshold: 0.70,\n }\n );\n\n // Returns: [{ id, score: 0.87, classification: 'CONFIRMS', metadata }]\n for (const match of similar) {\n console.log(`${match.classification}: ${match.id} (${match.score})`);\n }\n\n return { similar };\n};\n```\n\n### Classification Thresholds\n\nResults are automatically classified based on similarity score:\n\n| Classification | Default Threshold | Meaning |\n|----------------|-------------------|---------|\n| `DUPLICATE` | >= 0.90 | Near-identical content |\n| `CONFIRMS` | >= 0.85 | Strongly supports same claim |\n| `RELATED` | >= 0.75 | Topically related |\n| `NEW` | < 0.75 | No significant match |\n\nCustomize thresholds per use case:\n\n```typescript\nsearch: {\n classify: {\n DUPLICATE: 0.95, // Stricter duplicate detection\n CONFIRMS: 0.88,\n RELATED: 0.70, // Broader \"related\" category\n },\n}\n```\n\n### Gray Zone Detection\n\nFor cases where automatic classification isn't sufficient, use gray zone detection to get matches that need semantic evaluation:\n\n```typescript\nconst results = await embeddings.claimSimilarity.findWithGrayZone(\n 'Some claim text',\n { min: 0.60, max: 0.85 }\n);\n\n// Returns structured results:\n// {\n// high_confidence: [...], // Score >= 0.85 (auto-classified)\n// gray_zone: [...] // 0.60 <= score < 0.85 (needs review)\n// }\n\n// Process high confidence matches automatically\nfor (const match of results.high_confidence) {\n await markAsDuplicate(match.id);\n}\n\n// Queue gray zone for semantic review\nfor (const match of results.gray_zone) {\n await queueForReview(match.id, match.score);\n}\n```\n\n### Generate Embeddings Directly\n\nGenerate embeddings without searching:\n\n```typescript\nconst embeddings = createEmbeddings(ctx.env);\n\n// Get raw embedding vector\nconst vector = await embeddings.claimSimilarity.embed(\n 'Text to embed'\n);\n// Returns: number[] (768 dimensions for bge-base)\n```\n\n### Multiple Search Configurations\n\nDefine different configurations for different use cases:\n\n```typescript\n// services/embeddings/story-similarity.ts\nexport default defineEmbedding({\n name: 'story-similarity',\n source: 'stories',\n search: {\n threshold: 0.65,\n limit: 20,\n classify: {\n DUPLICATE: 0.92,\n CONFIRMS: 0.80,\n RELATED: 0.65,\n },\n filters: ['streamId'],\n },\n});\n\n// services/embeddings/material-similarity.ts\nexport default defineEmbedding({\n name: 'material-similarity',\n source: 'materials',\n search: {\n threshold: 0.70,\n limit: 5,\n classify: {\n DUPLICATE: 0.95,\n CONFIRMS: 0.90,\n RELATED: 0.80,\n },\n filters: ['sourceType', 'organizationId'],\n },\n});\n```\n\nUsage:\n```typescript\nconst embeddings = createEmbeddings(ctx.env);\n\n// Different search behaviors for different content types\nconst similarClaims = await embeddings.claimSimilarity.search(text, opts);\nconst similarStories = await embeddings.storySimilarity.search(text, opts);\nconst similarMaterials = await embeddings.materialSimilarity.search(text, opts);\n```\n\n### Table-Level vs Service-Level\n\n| Feature | Table-level (`defineTable`) | Service-level (`defineEmbedding`) |\n|---------|----------------------------|----------------------------------|\n| Auto-embed on INSERT | ✅ | ❌ |\n| Auto-embed on UPDATE | ✅ | ❌ |\n| Custom search functions | ❌ | ✅ |\n| Classification thresholds | ❌ | ✅ |\n| Gray zone detection | ❌ | ✅ |\n| Filterable searches | ❌ | ✅ |\n| Queue message triggers | ❌ | ✅ |\n\n**Use both together:**\n- `defineTable` with `embeddings` config for automatic embedding generation\n- `defineEmbedding` for typed search functions with classification"
|
|
343
379
|
},
|
|
344
380
|
"stack/vector": {
|
|
345
381
|
"title": "Vector & AI",
|
|
@@ -380,6 +416,15 @@ export const TOPIC_LIST = [
|
|
|
380
416
|
"account-ui/with-quickback",
|
|
381
417
|
"account-ui/worker",
|
|
382
418
|
"changelog",
|
|
419
|
+
"cms/actions",
|
|
420
|
+
"cms/components",
|
|
421
|
+
"cms/connecting",
|
|
422
|
+
"cms",
|
|
423
|
+
"cms/inline-editing",
|
|
424
|
+
"cms/schema-format",
|
|
425
|
+
"cms/schema-registry",
|
|
426
|
+
"cms/security",
|
|
427
|
+
"cms/table-views",
|
|
383
428
|
"compiler/cloud-compiler/authentication",
|
|
384
429
|
"compiler/cloud-compiler/cli",
|
|
385
430
|
"compiler/cloud-compiler/endpoints",
|