thatcher
v1.0.43
Published
A config-driven application framework for building data-intensive web apps without code.
Maintainers
Readme
Thatcher SDK
Configuration-Driven Application Framework for Data-Intensive Web Apps
Thatcher is a complete extraction of the moonlanding application framework. Build full-featured CRUD applications with workflows, permissions, and external integrations — all through YAML configuration, without writing code.
Features
- Zero-Code CRUD — Define entities in YAML; automatic REST API, UI, and database schema
- Workflow Engine — State machine with transitions, locks, and permissions
- Role-Based Access Control — Fine-grained permission templates per entity
- Plugin System — Extend entities with hooks, validators, and custom fields
- External Integrations — Google Drive/Gmail, Email (SMTP), PDF generation
- Hot Reload — Config changes reflect instantly without restart
- Production-Ready — busybase (LanceDB) document store, vector search, metrics, audit logging
Quick Start
# 1. Install globally (or use bun x)
npm install -g thatcher
# 2. Generate starter config
thatcher example
# 3. Start the server
thatcher startOr use directly with Bun:
bun x thatcher startConfiguration
Thatcher is driven by a single thatcher.config.yml file:
# thatcher.config.yml
roles:
admin:
hierarchy: 0
label: Admin
permissions_scope: global
user:
hierarchy: 1
label: User
permissions_scope: assigned
permission_templates:
basic:
admin: [list, view, create, edit, delete, manage_settings]
user: [list, view]
entities:
item:
label: Item
label_plural: Items
fields:
name:
type: text
required: true
description:
type: textarea
status:
type: enum
options: [active, archived]
default: active
workflows:
simple:
stages:
- draft
- active
- completed
system:
pagination:
default_page_size: 20
max_page_size: 100That's it. Start the server and you have:
- busybase
itemtable (schemaless; created on first insert) - REST endpoints:
GET/POST/PUT/DELETE /api/item - Role-based permissions enforced
- State transitions via
POST /api/item/:id/transition
Programmatic Usage
import { createThatcher } from 'thatcher';
const thatcher = createThatcher({
config: './thatcher.config.yml',
databasePath: './data/app.db',
});
// Initialize (loads config, migrates DB, registers plugins)
await thatcher.init();
// Start HTTP server
await thatcher.startServer({ port: 3000 });
// Use APIs directly
const items = await thatcher.list('item');
const item = await thatcher.get('item', 'id123');
const created = await thatcher.create('item', { name: 'Test' }, { id: 'user1' });
// Workflow transitions
await thatcher.transition('item', item.id, 'simple', 'active', { id: 'user1', role: 'admin' });
// Permissions
const canEdit = await thatcher.can(user, thatcher.getEntitySpec('item'), 'edit');CLI Commands
| Command | Description |
|---------|-------------|
| thatcher start | Start server in production mode |
| thatcher dev | Start with hot reload enabled |
| thatcher migrate | Run database migrations only |
| thatcher validate | Validate configuration file |
| thatcher console | Open REPL with thatcher API |
| thatcher example | Generate example config |
Architecture
Core Components
thatcher/
├── src/
│ ├── index.js # Main entry: createThatcher()
│ ├── cli.js # CLI commands
│ ├── config/
│ │ ├── config-loader.js # YAML loading & validation
│ │ ├── spec-helpers.js # Entity spec utilities
│ │ ├── env.js # Environment config
│ │ └── constants.js # HTTP codes, statuses
│ ├── lib/
│ │ ├── busybase-store.js # busybase data layer (async CRUD)
│ │ ├── query-engine.js # Read operations (GET, search)
│ │ ├── query-engine-write.js # Write operations (CRUD)
│ │ ├── config-generator-engine.js # Spec builder
│ │ ├── hook-engine.js # Event system
│ │ ├── workflow-engine.js # State machines
│ │ ├── auth-middleware.js # Auth checks
│ │ ├── crud-factory.js # Handler factory
│ │ ├── crud-handlers.js # HTTP handlers
│ │ ├── validate.js # Validation
│ │ └── logger.js # Structured logging
│ ├── services/
│ │ └── permission.service.js # Authorization
│ ├── adapters/
│ │ ├── google-auth.js # Google OAuth
│ │ └── google-drive.js # Drive file operations
│ ├── plugins/
│ │ └── index.js # Plugin auto-discovery
│ └── server/
│ └── server.js # HTTP server
└── package.jsonHow It Works
- Configuration Load —
master-config.ymlparsed into memory - Spec Generation — For each entity, ConfigEngine builds full spec from base + overrides + plugins
- Database Migration — Tables created/updated from spec fields (idempotent)
- Plugin Registration —
.plugin.jsfiles extend entity behavior - Request Handling — Generic CRUD handlers enforce permissions, validate, execute hooks
Entity Specification
Each entity derives from config:
| Config Key | Purpose |
|-----------|---------|
| fields | Column definitions (type, required, ref, enum, etc.) |
| permission_template | Maps roles → allowed actions |
| workflow | State machine name for lifecycle |
| row_access | Scoping: team, assigned, client |
| list.defaultSort | Default list ordering |
| has_* flags | UI features (PDF, collaboration, notifications) |
Permissions
Permission templates define role capabilities per entity:
permission_templates:
standard:
admin: [list, view, create, edit, delete, export]
user: [list, view]Actions: list, view, create, edit, delete, archive, export, manage_settings, etc.
Row access controls which records a user can see:
team— Only records in user's teamassigned— Only records assigned to userclient— Only records for user's clientassigned_or_team— Either condition
Workflows
State machines with transitions:
workflows:
engagement_lifecycle:
state_field: stage
stages:
- name: draft
label: Draft
forward: [review, submitted]
readonly: true
- name: review
label: In Review
forward: [approved, rejected]
backward: [draft]
requires_role: [manager, partner]
- name: closed
label: Closed
entry: partner_onlyTransitions validated automatically:
- Only allowed transitions
- Role requirements
- Lockout period (configurable)
- Readonly state blocks
Hooks
Listen to lifecycle events:
// my-entity.plugin.js
export default {
entityName: 'item',
hooks: [
{
event: 'create:item:after',
handler: async ({ entity, id, data, user }) => {
console.log(`Item ${id} created by ${user.id}`);
// Send notification, sync external system, etc.
},
},
],
};Hook naming: <timing>:<entity>:<phase>
create:entity:before/afterupdate:entity:before/afterdelete:entity:before/aftertransition:entity:before/after- Custom:
upload_files:entity:after,resolve_highlight:review:after
Plugins
Extend entities with custom fields and behavior:
// plugins/custom-item.plugin.js
export default {
entityName: 'item',
fields: {
custom_field: {
type: 'text',
label: 'Custom Field',
required: false,
},
},
validators: {
validateCustom: (data) => {
if (data.custom_field && !data.custom_field.startsWith('X')) {
return 'Custom field must start with X';
}
return null;
},
},
};All .plugin.js files in plugins/ directory auto-loaded on startup.
External Integrations
Google OAuth & Drive
Set env vars:
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GOOGLE_SERVICE_ACCOUNT_PATH=./service-account.json
GOOGLE_DRIVE_FOLDER_ID=...Drive operations via @/adapters/google-drive:
import { uploadFile, downloadFile, exportToPdf } from 'thatcher/adapters/google-drive';
await uploadFile('./local.pdf', 'Document.pdf', { folderId: '...' });
const pdf = await exportToPdf('docId');Email (SMTP)
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USER=...
EMAIL_PASSWORD=...
[email protected]Use via @/lib/email-sender (included).
API Reference
All APIs accessible via thatcher instance:
CRUD
thatcher.list(entity, where, opts)→ Arraythatcher.get(entity, id)→ objectthatcher.create(entity, data, user)→ objectthatcher.update(entity, id, data, user)→ objectthatcher.delete(entity, id)→ void
Search
thatcher.search(entity, query, where, opts)→ Array (uses FTS)
Workflow
thatcher.transition(entityType, entityId, workflowName, toState, user, reason)→ objectthatcher.getAvailableTransitions(workflowName, currentState, user, record)→ Array
AuthZ
thatcher.can(user, spec, action)→ booleanthatcher.requirePermission(user, spec, action)→ throws if denied
Config
thatcher.getConfigEngine()→ ConfigGeneratorEnginethatcher.getEntitySpec(entityName)→ spec objectthatcher.getAllEntities()→ Array
Direct DB
thatcher.withTransaction(cb)→ Promise
Environment Variables
| Variable | Purpose | Default |
|----------|---------|---------|
| PORT | Server port | 3000 |
| BUSYBASE_DIR | busybase data directory | busybase_data |
| NODE_ENV | development | production | development |
| DEBUG | Enable debug logging | false |
| GOOGLE_CLIENT_ID | OAuth client ID | — |
| GOOGLE_CLIENT_SECRET | OAuth client secret | — |
| GOOGLE_DRIVE_FOLDER_ID | Root Drive folder | — |
| EMAIL_HOST | SMTP host | smtp.gmail.com |
| EMAIL_PORT | SMTP port | 587 |
| EMAIL_USER | SMTP username | — |
| EMAIL_PASSWORD | SMTP password | — |
Publishing
Thatcher is published to npm as thatcher. Every push to main triggers:
- Bump version (from git tags or conventional commits)
- Build & test
- Publish to npm registry
- Create GitHub release
CI/CD ready via included GitHub Actions workflow.
Runtime: Bun vs Node
Thatcher targets Bun as its primary runtime (engines.bun in package.json,
a bun.lock in the repo root, and src/cli.js carries a #!/usr/bin/env bun
shebang), but most of the codebase is plain ES modules with no Bun-only API
calls, so a Node-only consumer can run everything except the two bun run
scripts as-is.
| Script | Command | Runtime |
|--------|---------|---------|
| npm run dev | bun run src/cli.js dev | Bun required — invoked via bun run; thatcher dev also enables hot reload (server.hotReload: true), and the CLI's own shebang is #!/usr/bin/env bun. |
| npm run start | bun run src/cli.js start | Bun required — same bun run invocation as dev, just without hot reload. |
| npm test | node test.js | Plain Node — runs fine under node test.js directly. |
| npm run lint | eslint src/ | Tool-agnostic — standard ESLint CLI, no runtime dependency. |
| npm run typecheck | tsc --noEmit | Tool-agnostic — standard TypeScript CLI. |
In practice, src/cli.js itself has no Bun-only API calls (no Bun.*
globals) — the bun run wrapping in dev/start is a process-launcher
choice, not a hard code dependency, so node src/cli.js start / node
src/cli.js dev also work for a Node-only deployer. The one place Bun is
harder to avoid is the busybase dependency, whose published src/*.js are
bun-build outputs (per its own build process) rather than hand-authored
Node-targeted files. A Node-only consumer/deployer can therefore run
test/lint/typecheck and the CLI itself with plain node, but should
expect the dev/start npm scripts as published to require Bun on PATH
unless invoking src/cli.js directly with node.
License
MIT — Extracted from moonlanding with all functionality preserved.
