refactor: move skill to skills/inkreach-teable (from .agents/skills/)

git mv rename (history preserved); install.sh + README updated.
The skill is consumed via user-level install (~/.zcode/skills), so
in-repo .agents discovery was unnecessary.
This commit is contained in:
yeuimu
2026-09-23 04:07:39 +00:00
parent b7eb065c20
commit 76ef02d659
14 changed files with 7 additions and 7 deletions
@@ -0,0 +1,209 @@
# Automation Guide
Automations (workflows) are event-driven: a **trigger** fires, then **actions** run in topological order. inkreach-teable's automation engine is self-built — use the `inkreach-teable` CLI (not official `teable`).
> **Required reading before configuring triggers**: [trigger-types.md](../api-reference/trigger-types.md)
> **Required reading before writing script actions**: [action-types.md](../api-reference/action-types.md)
## Mental Model
```
[Trigger] → [Action 1] → [Action 2] → ... → [triggerEnd]
```
- A workflow has **exactly one trigger node** (type `trigger`) and N action nodes (type `action`).
- Nodes connect via **edges** (linear chain; each node has at most one upstream — no parallel branches).
- **Activating** freezes a snapshot; execution runs against the snapshot, not the draft.
- `hasDraft: true` = unactivated changes exist; re-`activate` to apply.
## Available Commands
| Command | Purpose |
|---------|---------|
| `workflow list` | List all workflows in the base |
| `workflow get <id>` | Detail (nodes + edges + config) |
| `workflow create --name <n>` | Create (auto-adds a recordCreated trigger) |
| `workflow update <id> [--name] [--description]` | Rename |
| `workflow delete <id>` | Soft-delete |
| `workflow activate <id>` | Snapshot + register trigger + create auto-token |
| `workflow deactivate <id>` | Unregister trigger |
| `workflow test <id>` | Mock-input test run (no trigger, no queue) |
| `trigger set <id> --type <T> ...` | Set trigger type + config |
| `action add <id> --type <T> ...` | Add an action node |
| `action update <id> <nodeId> ...` | Update an action node |
| `action delete <id> <nodeId>` | Remove an action node |
| `secret set/list/delete` | Manage script secrets |
| `runs list/get/rerun` | Execution history |
| `fire button-click/webhook` | Manually trigger for testing |
## Trigger Selection
| User scenario | `--type` | Key options |
|--------------|----------|-------------|
| React to new records | `recordCreated` | `--table-id` |
| React to record changes | `recordUpdated` | `--table-id`, `--field-ids` (empty = all) |
| React when a condition matches | `recordCondition` | `--table-id`, `--conditions` (required) |
| React to create OR update | `recordCreatedOrUpdated` | `--table-id`, `--conditions` (optional) |
| Form submission | `formSubmitted` | `--table-id`, `--form-ids <csv>` |
| Schedule (only type+interval work) | `scheduledTime` | `--schedule '{"timing":{"type":"minutes","interval":5}}'` |
| Button field click | `buttonClick` | `--table-id`, `--field-ids` |
| External HTTP POST | `webhook` | (none; auth backend-ignored) |
**Batch processing** (record triggers only): add `--batch '{"enabled":true,"batchWindow":2000,"batchSize":100}'` to merge events within a window into a single run. The script receives an array of records.
Full config details: [trigger-types.md](../api-reference/trigger-types.md).
## Action Selection
| Goal | `--type` | Key options |
|------|----------|-------------|
| Run JS code (transform, call APIs, compute) | `script` | `--code '<js>'` or `--code-file <path>`, `--timeout <ms>` |
| Send email | `sendEmail` | `--to`, `--subject`, `--body <html>` |
| Call external HTTP | `httpRequest` | `--url`, `--method`, `--headers <json>`, `--req-body <json>` |
| Create records | `createRecord` | `--target-table <id>`, `--records <json>` |
| Update records | `updateRecord` | `--target-table <id>`, `--records <json>` |
| Query records | `getRecords` | `--target-table <id>`, `--query <json>` |
| Send system notification (站内信) | `sendNotification` | `--to-user <usrId>`, `--title`, `--message`, `--severity`, `--path` |
Full config details: [action-types.md](../api-reference/action-types.md).
> ⚠️ `action update --config '<json>'` takes the **flat action config** (e.g. `{"code":"...","timeout":120000}`), NOT `{"config":{...}}`. A wrapped config deploys an empty script that "succeeds" in ~1ms with `result: {}` and breaks downstream `{{nodeId.result.*}}` references. After any `--config` deploy, verify with `workflow get --json` that `nodes[].config.code` is non-empty. Details: [action-types.md § script](../api-reference/action-types.md).
## Creation Workflow
```bash
# 1. Gather IDs
inkreach-teable table get tblXXX # confirm table
inkreach-teable field list tblXXX # find field ids for triggers
# 2. Create workflow
inkreach-teable workflow create --name "My automation"
# → returns wflXXX
# 3. Set trigger
inkreach-teable trigger set wflXXX --type recordUpdated \
--table-id tblXXX --field-ids '["fldStatus"]'
# 4. Add action(s)
inkreach-teable action add wflXXX --type script \
--code 'return { status: triggerInput.record.fields.Status }'
# 5. (Optional) add secrets the script needs
inkreach-teable secret set wflXXX --name API_KEY --value sk_xxx
# 6. Activate
inkreach-teable workflow activate wflXXX
# → returns snapshotId
# 7. Test
inkreach-teable fire button-click tblXXX recXXX fldXXX # for buttonClick
# or just perform the triggering action (create/update a record, POST webhook...)
inkreach-teable runs list wflXXX
```
## Editing an Existing Workflow
```bash
# Change trigger config
inkreach-teable trigger set wflXXX --type buttonClick --table-id tblXXX --field-ids '["fldNew"]'
# Add another action
inkreach-teable action add wflXXX --type sendEmail --to a@b.com --subject Done --body '<p>ok</p>'
# Remove an action
inkreach-teable action delete wflXXX node-12345
# RE-ACTIVATE to snapshot the changes
inkreach-teable workflow activate wflXXX
```
`trigger set` / `action add/update/delete` internally do GET→modify→PUT the whole graph, so they're safe. But you **must re-activate** for changes to take effect at runtime.
## Secrets
Scripts often need API keys. Never hardcode — use secrets.
```bash
inkreach-teable secret set wflXXX --name OPENAI_KEY --value sk-xxx --description "for AI calls"
inkreach-teable secret list wflXXX # names only — value never returned
inkreach-teable secret update wflXXX secXXX --value sk-new
inkreach-teable secret delete wflXXX secXXX
```
In a script action, read via `process.env.OPENAI_KEY`. Secrets are AES-encrypted at rest.
## Testing / Triggering
Most triggers fire from real events (record create/update, form submit, schedule). Two triggers are manually fireable for testing:
```bash
# Button field click
inkreach-teable fire button-click <tableId> <recordId> <fieldId>
# Webhook (no auth needed)
inkreach-teable fire webhook <baseId> <workflowId> --body '{"event":"test"}'
```
For other triggers, test by performing the real action (create/update a record, submit the form). Then inspect:
```bash
inkreach-teable runs list wflXXX --status failed
inkreach-teable runs get wflXXX <executionId>
# rerun a failed execution
inkreach-teable runs rerun wflXXX <executionId>
```
### `workflow test` vs real fire
`workflow test <id>` runs actions synchronously with **mock input** (a fake
`{record:{id:'rec_test',fields:{}}, user:{...}}`). It does NOT fire the
trigger or use the queue. Two caveats:
- **Upstream outputs are absent in test mode** — if a script reads
`triggerInput.record.fields.<X>`, it gets `{}` because the mock record has
no fields. Test only validates that the script *runs without throwing*, not
that it produces correct data.
- For data-dependent validation, do a **real fire** (button-click / webhook)
or perform the real triggering action, then check `runs list`.
## Variable References (cross-node data)
An action can reference upstream nodes' output in string fields via `{{nodeId.path}}` (or the compatible `{{index.path}}` positional form):
- `{{n0.record.id}}` — trigger record's id (n0 = trigger node)
- `{{n0.record.fields.Status}}` — trigger record's Status field
- `{{2.result}}` — output of the first action (numeric index form; index = topological execution order, trigger is always 1)
Both forms resolve to the same value — `{{n0.record.id}}` ≡ `{{1.record.id}}`. Use `{{nodeId.path}}` in new workflows for clarity.
> **Empty-value semantics**: if a variable resolves to an empty string, the node sees the literal empty value. For `sendNotification`, an empty `toUserId` makes the node **silently skip** (status `succeeded`, `sentCount: 0`) instead of failing — so a script action that returns an empty recipient implements "notify only when X" conditional patterns. An empty `message` still fails with `Missing required fields: message`.
## Common Patterns
### Notify on status change
```bash
inkreach-teable workflow create --name "Status→email"
inkreach-teable trigger set wflXXX --type recordUpdated --table-id tblXXX --field-ids '["fldStatus"]'
inkreach-teable action add wflXXX --type sendEmail \
--to ops@x.com --subject 'Status changed' \
--body '{{n0.record.fields.Status}}'
inkreach-teable workflow activate wflXXX
```
### Daily digest
```bash
inkreach-teable trigger set wflXXX --type scheduledTime \
--schedule '{"timing":{"type":"hours","interval":24}}'
```
### Button → script → update another table
```bash
inkreach-teable trigger set wflXXX --type buttonClick --table-id tblA --field-ids '["fldGo"]'
inkreach-teable action add wflXXX --type script --code '
const r = triggerInput.record;
// call back into the instance API using the auto-injected token
const res = await fetch(triggerInput._apiBaseUrl + "/api/table/tblB/record", {
method: "POST",
headers: { Authorization: "Bearer " + triggerInput._accessToken, "Content-Type": "application/json" },
body: JSON.stringify({ fields: { Source: r.id } })
});
return { created: await res.json() };
'
```
@@ -0,0 +1,249 @@
# CLI Reference
Complete command reference for `inkreach-teable`. Global options apply to all commands.
## Global Options
| Option | Description |
|--------|-------------|
| `--endpoint <url>` | Backend URL (default `http://localhost:3003`) |
| `--token <pat>` | Personal Access Token |
| `--base-id <id>` | Default base id |
| `--json` | Output raw JSON (for agent parsing) |
| `--debug` | Print HTTP requests/responses to stderr |
| `-v, --version` | Show version |
Config precedence: global flag > env (`INKREACH_TEABLE_ENDPOINT`/`INKREACH_TEABLE_TOKEN`/`INKREACH_TEABLE_BASE_ID`) > `~/.inkreach-teable/config.json`.
## auth
```bash
inkreach-teable auth status # verify token + show user
inkreach-teable auth login --endpoint <url> --token <PAT> [--base-id <id>]
inkreach-teable auth token list
inkreach-teable auth token create --name <n> [--scopes <csv>] [--base-ids <csv>] [--full-access] [--expires <date>]
inkreach-teable auth token delete <id>
```
- `auth token create` returns the full token **only once**. Default scopes: `automation|read,automation|create,automation|update,automation|delete,table|read,record|read,record|create,record|update`.
## base / table / field / record / view
These resources use the official-teable-compatible API. Full guide: [tables-fields.md](tables-fields.md).
```bash
# base
inkreach-teable base list
inkreach-teable base get <baseId>
# table
inkreach-teable table list [--base-id <id>]
inkreach-teable table get <tableId>
inkreach-teable table create --name <n> [--base-id <id>] [--fields '<json>']
inkreach-teable table update <tableId> --name <n>
inkreach-teable table delete <tableId>
# field (get/update/visible-choices take <tableId> <fieldIdOrName> — id or display name, auto-resolved)
inkreach-teable field list <tableId> [--with-options]
# --with-options: OPTIONS summary column + per-choice visibility conditions for select fields
inkreach-teable field get <tableId> <fieldIdOrName>
# human mode renders select choices with their visibilityFilter conditions; --json for raw
inkreach-teable field create --table-id <tid> --name <n> [--type <t|alias>] [--options '<json>'] \
[--description <d>] [--lookup-options '<json>'] [--is-lookup]
# --type optional: inferred from --options shape / --name when omitted
inkreach-teable field update <tableId> <fieldIdOrName> [--name <n>] [--options '<json>'] # --options goes via convert API
inkreach-teable field visible-choices <tableId> <recordId> <fieldIdOrName> # evaluate select-choice visibilityFilter
inkreach-teable field delete <tableId> <fieldId>
# record
inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] [--page-size <n>] [--page <n>] [--search <kw>]
inkreach-teable record get-one <recordId>
inkreach-teable record create --table-id <tid> --fields '<json>' # single
inkreach-teable record create --table-id <tid> --records '<json>' # batch
inkreach-teable record update --table-id <tid> --records '<json>'
inkreach-teable record delete --table-id <tid> --record-ids <csv>
# view
inkreach-teable view list <tableId>
inkreach-teable view get <viewId>
inkreach-teable view create --table-id <tid> --name <n> [--type <t>]
inkreach-teable view update <viewId> [--name <n>]
inkreach-teable view delete <viewId>
# view presets (view-scoped shared named filter/sort bundles, applied via toolbar)
inkreach-teable preset list <tableId> [--view-id <vid>]
inkreach-teable preset create --table-id <tid> --view-id <vid> --name <n> [--description <d>] \
[--filter '<json>'] [--sort '<json>'] [--group '<json>'] [--options '<json>'] [--column-meta '<json>']
inkreach-teable preset update <presetId> --table-id <tid> [--name <n>] [--description <d>] [--filter '<json>' ...]
inkreach-teable preset delete <presetId> --table-id <tid>
```
## workflow
```bash
inkreach-teable workflow list [--base-id <id>]
inkreach-teable workflow get <workflowId> [--base-id <id>]
inkreach-teable workflow create --name <n> [--description <d>] [--base-id <id>]
inkreach-teable workflow update <workflowId> [--name <n>] [--description <d>] [--base-id <id>]
inkreach-teable workflow delete <workflowId> [--base-id <id>]
inkreach-teable workflow activate <workflowId> [--base-id <id>]
inkreach-teable workflow deactivate <workflowId> [--base-id <id>]
inkreach-teable workflow test <workflowId> [--base-id <id>]
```
Full automation flow: [automation-guide.md](automation-guide.md).
## trigger
```bash
inkreach-teable trigger set <workflowId> --type <T> [options] [--base-id <id>]
```
| `--type` | Required options |
|----------|------------------|
| `recordCreated` | `--table-id` |
| `recordUpdated` | `--table-id`, `--field-ids '<json>'` |
| `recordCondition` | `--table-id`, `--conditions '<json>'` |
| `recordCreatedOrUpdated` | `--table-id` |
| `formSubmitted` | `--table-id`, `--form-ids <csv>` |
| `scheduledTime` | `--schedule '{"timing":{"type":"minutes","interval":5}}'` |
| `buttonClick` | `--table-id`, `--field-ids '<json>'` |
| `webhook` | (none) |
Common optional: `--batch '<json>'` (record triggers only).
Details: [trigger-types.md](../api-reference/trigger-types.md).
## action
```bash
inkreach-teable action add <workflowId> --type <T> [options] [--base-id <id>]
inkreach-teable action update <workflowId> <nodeId> [--config '<json>'] [--code <js>] [--code-file <path>] [--name <n>]
inkreach-teable action delete <workflowId> <nodeId> [--base-id <id>]
```
Per-type options:
| `--type` | Options |
|----------|---------|
| `script` | `--code <js>` / `--code-file <path>`, `--timeout <ms>` |
| `sendEmail` | `--to <email>`, `--subject <s>`, `--body <html>` |
| `httpRequest` | `--url <u>`, `--method <m>`, `--headers <json>`, `--req-body <json>` |
| `createRecord` | `--target-table <id>`, `--records <json>` |
| `updateRecord` | `--target-table <id>`, `--records <json>` |
| `getRecords` | `--target-table <id>`, `--query <json>` |
| `sendNotification` | `--to-user <usrId>`, `--title <s>`, `--message <s>`, `--severity <s>`, `--path <s>` |
Details: [action-types.md](../api-reference/action-types.md).
## secret
```bash
inkreach-teable secret list <workflowId> [--base-id <id>]
inkreach-teable secret set <workflowId> --name <n> --value <v> [--description <d>] [--base-id <id>]
inkreach-teable secret update <workflowId> <secretId> [--name <n>] [--value <v>] [--description <d>] [--base-id <id>]
inkreach-teable secret delete <workflowId> <secretId> [--base-id <id>]
```
Values are write-only — `list` never returns them. Scripts read via `process.env.<NAME>`.
## runs
```bash
inkreach-teable runs list <workflowId> [--status failed|succeeded|running] [--base-id <id>]
inkreach-teable runs get <workflowId> <executionId> [--base-id <id>]
inkreach-teable runs rerun <workflowId> <executionId> [--base-id <id>]
```
`rerun` only works on `failed` executions (backend validates).
## fire
```bash
inkreach-teable fire button-click <tableId> <recordId> <fieldId>
inkreach-teable fire webhook <baseId> <workflowId> [--body '<json>'] [--method <m>]
```
Manually trigger for testing. Webhook needs no auth (backend is `@Public`).
## subscription
```bash
inkreach-teable subscription list
inkreach-teable subscription get <subscriptionId>
inkreach-teable subscription create --name <n> --table-id <tid> [options]
inkreach-teable subscription update <subscriptionId> [options]
inkreach-teable subscription delete <subscriptionId>
inkreach-teable subscription events <subscriptionId>
inkreach-teable subscription send <subscriptionId>
```
Record digest/instant email subscriptions. Options (`--filter`, `--send-mode`,
`--schedule-*`, `--trigger-on-enter`, …) and semantics: [subscription.md](subscription.md).
## sql
```bash
inkreach-teable sql '<SELECT ...>' [--base-id <id>]
```
Read-only. Must use `dbTableName`/`dbFieldName` (from `table get`/`field get`), double-quote identifiers, add `LIMIT 100`.
```bash
inkreach-teable sql 'SELECT "name" FROM "bseXXX"."tbl_abc" LIMIT 10'
```
## plugin
Manage plugins and developer whitelist.
```bash
# List plugins registered in the base
inkreach-teable plugin list
# Register a URL-served plugin (runtimeType=url; status published immediately)
# positions: "base"|"panel"|"view"|"dashboard"|"contextMenu"
inkreach-teable plugin register --url <url> --name <name> [--positions '["panel"]'] [--logo <url>] [--description <d>]
# Bundle (upload) plugins use a different flow — see skill guides/tables-fields.md
# or api docs: create kebab-id plugin (developer whitelist) → upload → admin publish.
# Developer whitelist (admin only)
inkreach-teable plugin dev list
inkreach-teable plugin dev allow <userIdOrEmail> # add (userId or email)
inkreach-teable plugin dev revoke <userIdOrEmail> # remove
# Install / uninstall (creates a base-level mount node)
inkreach-teable plugin install --plugin <pluginId> --name "My Plugin" --position base
# ⚠️ For view/dashboard-position plugins, run `plugin config enable` BEFORE
# install, or the install API returns 403 RESTRICTED_RESOURCE
# 'Plugin is not enabled for this base'.
inkreach-teable plugin installs # list installs in current base
inkreach-teable plugin uninstall --install <installId>
# Enable/disable plugins
inkreach-teable plugin config enable --plugin <pluginId>
inkreach-teable plugin config disable --plugin <pluginId>
# CSP apiOrigins (external origins plugins may fetch)
inkreach-teable plugin csp list
inkreach-teable plugin csp set <pluginId> --origins '["http://192.168.x.x:8001"]'
```
Developer whitelist is admin-only (requires `user.isAdmin=true`). Routes:
`/api/plugin-developers` (GET/POST/DELETE).
## Output Modes
- **Human (default)**: tables / key-values, colored where supported.
- **`--json`**: compact raw JSON on stdout. Errors in JSON mode:
```json
{ "error": { "status": 404, "code": "NOT_FOUND", "message": "..." } }
```
(exit code 1)
## Error Troubleshooting
1. `inkreach-teable auth status` — token valid? endpoint reachable?
2. Verify IDs: `table get`, `field get`, `workflow get` (typo'd IDs → 404).
3. Check permissions: token scopes + base role. `403` → role too low.
4. `--debug` to see the raw HTTP request/response.
@@ -0,0 +1,170 @@
# Development & Deployment
> This guide documents the **inkreach-teable application repo**
> (`ssh://git@gitea.inkreach.cc:2222/inkreachhub/inkreach-teable.git`) — the
> deployed instance this CLI operates on — not this CLI repo. Paths like
> `apps/nestjs-backend/` refer to that repo's checkout.
How to build, restart, and debug this inkreach-teable instance. Covers the
dual-process architecture, known gotchas, and test infrastructure. For daily
operations (tables/automation), use the `inkreach-teable` CLI instead — this guide is
for changing the source code itself.
## Architecture
Two **separate production processes** managed by systemd user services. Do NOT
use `pnpm dev` as a long-running service — the embedded Next.js dev server
inside NestJS is unstable and will hang or OOM-crash.
| Service | Command | Port | Mode |
|---------|---------|------|------|
| `teable-backend.service` | `node dist/index.js` | `:3003` | `NODE_ENV=production`, `BACKEND_SKIP_NEXT_START=true` |
| `teable-frontend.service` | `next start -p 3002 -H 0.0.0.0` | `:3002` | `NODE_ENV=production` |
Users access `:3002`; the backend `:3003` serves the API only.
## Build & Restart
### Backend (no hot reload)
After editing anything under `apps/nestjs-backend/src/`:
```bash
cd apps/nestjs-backend && pnpm build # webpack, ~18s
systemctl --user restart teable-backend.service
```
`dist/` from `pnpm dev:swc` is hot-update fragments, NOT a valid artifact — always `nest build`.
### Frontend
```bash
pnpm -r -F './packages/**' build # build dependent packages first
pnpm --filter @teable/app build-fast
systemctl --user restart teable-frontend.service
```
Browser cache is aggressive — hard-refresh (Ctrl+Shift+R) after a rebuild.
### Recover from OOM crash
If `:3003` is unreachable but systemd says `active`:
```bash
systemctl --user stop teable-backend.service
pkill -9 -f 'dist/index.js'
systemctl --user start teable-backend.service
```
## Known Gotchas (from real debugging)
### Webpack cache may not rewrite `dist/index.js` after a workspace package rebuild
After rebuilding a workspace package (e.g. `@teable/openapi` dist), `pnpm build` in
`apps/nestjs-backend` can report "compiled successfully" while leaving `dist/index.js`
untouched (node_modules paths are treated as immutable cache inputs). Symptom: backend
behavior unchanged after a code change. Fix: `rm apps/nestjs-backend/dist/index.js`
then rebuild, and verify with `grep '<your-new-string>' dist/index.js`.
### Workspace packages must be rebuilt BEFORE the backend (else stale dist ships)
Distinct from the cache trap above: `apps/nestjs-backend` never compiles `@teable/*`
sources — it resolves `@teable/core`, `@teable/v2-core`, `@teable/openapi` … via
`node_modules` symlinks to `packages/*/dist`. `@teable/v2-core` in particular is a
webpack EXTERNAL: the running backend reads `packages/v2/core/dist/index.cjs` directly
at runtime, so a backend rebuild does not even copy it.
SILENT FAILURE MODE (hit 2026-08-18, select-defaultValue cascade fix): edit
`packages/core/src/…` + `packages/v2/core/src/…` → only rebuild backend + restart →
live API keeps the old behavior, no error anywhere. Correct order:
```bash
pnpm --filter @teable/core build # tsc → packages/core/dist/**
pnpm --filter @teable/v2-core build # tsdown → dist/index.cjs + dist/index.js
cd apps/nestjs-backend && rm -f dist/index.js && pnpm build # bundle + externals
# verify the new code actually shipped BEFORE restarting:
grep '<new-string>' packages/core/dist/<path>.js
grep '<new-string>' packages/v2/core/dist/index.cjs
# then the best-path restart (:3003)
```
(Many packages at once: `pnpm -F './packages/**' build`, then backend build.)
### Node v24 fetch drops the Authorization header
The global `fetch()` on Node v24 (undici) drops the `Authorization` request
header in certain cases (redirects, proxies). **Use `node:http`/`node:https`**
directly for any backend API call, or the `axios`-style clients. This is why
the `inkreach-teable` CLI uses `node:http` instead of `fetch`.
### Script sandbox has no `process` global
In automation script actions, `typeof process === 'undefined'`. Access secrets
via `process.env.NAME` (this is allowed — only `process.env` is exposed, not
`process` itself). Use `triggerInput._secrets`, `triggerInput._accessToken`,
`triggerInput._apiBaseUrl` for back-channel API calls. No `fs`, `eval`,
`Function`, or `child_process`.
### Script sandbox npm deps need npm on the service PATH
Script actions CAN use npm packages — declare them via the CLI
(`action add/update --dependencies '[{"name":"axios","version":"1.6.0"}]'`
or shorthand `axios@1.6.0,dayjs`) and `require('axios')` works in-sandbox.
The backend installs deps into a cache dir (`/tmp/teable-automation-deps/<hash>`)
via `spawn('npm', ['install', ...])`, keyed by `name@version`.
**Gotcha:** systemd user services default to `PATH=/usr/local/bin:/usr/bin:...`
which OMITS the nvm bin dir where `npm` lives → scripts with deps fail with
`Dependency install failed: spawn npm ENOENT`. Fix: add
`Environment=PATH=/root/.nvm/versions/node/vX/bin:/usr/local/sbin:...` to
`teable-backend.service`, `daemon-reload`, restart. Verify:
`inkreach-teable workflow test <wfId>` on a script with `require('dayjs')` →
`succeeded`.
### scheduledTime backend is a simplified implementation
The backend `scheduled-time.trigger.ts` only honors `timing.type` + `timing.interval`.
It ignores `triggerTime`, `weekdays`, `daysOfMonth`, `starting`, `ending`, `tz`.
`weeks` type is hardcoded to every Sunday. `OneTime` falls back to every 60s.
The scheduler is in-process `setInterval` — multi-instance deployments fire
duplicates. See [trigger-types.md § scheduledTime](../api-reference/trigger-types.md#6-scheduledtime--on-a-schedule).
### Webhook route mismatch
Frontend UI shows `/api/webhook/base/{baseId}/workflow/{workflowId}` but the
backend's real route is `/api/webhook/{baseId}/{workflowId}` (no `/base/` or
`/workflow/` segments). The CLI uses the real route. Webhook `authorization`
config is UI-only — backend does NOT validate it.
### Button field options capture stale isActive
A Button field's `options.workflow.isActive` is a snapshot from creation time.
After deactivating a workflow, the API validates the DB `is_active` instead —
a click returns 400 "not active". Re-`activate` the workflow, or recreate the
field. Always verify DB state, not field options.
### base_node pollution (sidebar perf)
Workflows create entries in the `base_node` table, which feeds the sidebar
navigation tree. Hundreds of test workflows → thousands of DOM nodes → jank.
The sidebar is now virtualized (`@tanstack/react-virtual`), but avoid
generating throwaway workflows; `deactivate` instead of `delete` to keep them
queryable.
### Key-name differences from official teable
The self-built automation uses `conditions` (not `filter`), `fieldIds` (not
`watchFieldIds`), category `recordCondition` (not `recordMatchesConditions`).
The `inkreach-teable` CLI handles these correctly; never use the official `teable`
CLI for automations. See [SKILL.md § Key Constraints](../SKILL.md#3-key-constraints).
## Test Infrastructure
Automation E2E tests live in `scripts/automation-test/`:
```bash
node scripts/automation-test/run-all.js # 59 cases: 8 triggers + 7 actions + cross-features
node scripts/stress-test/stress-test.js # concurrency stress test
```
Lib framework: `scripts/automation-test/lib.js`. Uses Node `http` (not fetch)
due to the auth-header bug. Test workflows are **deactivated, not deleted**
(retention policy) — the base has ~600 historical test workflows.
## Useful DB Queries
```bash
# Count workflows by active state
PGPASSWORD=<ask-maintainer> psql -h localhost -U teable -d teable -c \
"SELECT is_active, count(*) FROM workflow WHERE deleted_time IS NULL GROUP BY is_active;"
# Find button fields and their bound workflows
PGPASSWORD=<ask-maintainer> psql -h localhost -U teable -d teable -c \
"SELECT id, name, options->'workflow'->>'id' AS wf_id, options->'workflow'->>'isActive' AS active
FROM field WHERE type='button';"
# Recent executions
PGPASSWORD=<ask-maintainer> psql -h localhost -U teable -d teable -c \
"SELECT id, workflow_id, status, started_at FROM workflow_execution
ORDER BY started_at DESC LIMIT 10;"
```
@@ -0,0 +1,89 @@
# Instance Access
How to reach the deployed inkreach-teable instance, acquire credentials, and
verify health. This file describes the **reference deployment** — adapt
endpoint/ports for your own deployment.
## Connection
| Service | URL / Port | Mode | Purpose |
|---------|-----------|------|---------|
| Backend API | `http://localhost:3003` | production (`NODE_ENV=production`) | REST API, automation engine, websocket |
| Frontend | `http://localhost:3002` | production | End-user web UI (Next.js) |
| PostgreSQL | `localhost:5432` | — | Database (db: `teable`, user: `teable`) |
| Redis | `localhost:6379` | — | BullMQ queue for automation runs, distributed lock |
- The frontend reads `SOCKET_PORT` from `.env` to point its websocket at the backend — verify it equals the backend port (3003 on this host), not the default 3001.
- End users browse `:3002`. The backend `:3003` is the API only (`BACKEND_SKIP_NEXT_START=true` disables the embedded Next server).
- ⚠️ Port map differs from upstream Teable docs (:3000) — on this host `:3000` is held by an **unrelated container** (`inkreach-web`). Always verify with `ss -tlnp` before assuming.
## Health Checks
```bash
# Backend API up?
curl -s -o /dev/null -w '%{http_code}' http://localhost:3003/api/auth/signin -X POST \
-H 'Content-Type: application/json' -d '{}' # expect 401 (endpoint exists, bad creds)
# Frontend up?
curl -s -o /dev/null -w '%{http_code}' http://localhost:3002 # expect 307 or 200
# Ports listening?
ss -tlnp | grep -E ':3003|:3002|:5432|:6379'
```
If `:3003` is unreachable but systemd shows `active`, the worker likely OOM-crashed while the `pnpm` parent stayed alive:
```bash
systemctl --user stop teable-backend.service
pkill -9 -f 'dist/index.js'
systemctl --user start teable-backend.service
```
## Acquiring a PAT (Personal Access Token)
The CLI authenticates with a PAT (`teable_{id}_{encryptedSign}`). Two ways to get one:
### Option A — via the CLI (needs a session cookie or existing token)
```bash
inkreach-teable auth token create --name my-cli \
--scopes automation|read,automation|create,automation|update,automation|delete,table|read,record|read,record|create,record|update \
--base-ids bseXXX --expires 2099-12-31
# → prints full token ONCE
```
Note: `auth token list/create` may require a **session** (browser login) rather than a PAT — if you get 403, use Option B.
### Option B — via the web UI (recommended for the first token)
1. Log in to `http://localhost:3002` as an admin.
2. User menu → **Settings** → **Personal Access Tokens**.
3. Create a token with scopes: `automation|*`, `table|*`, `record|*`, `field|*`, `view|*` as needed. Limit to the base if desired.
4. Copy the `teable_xxx_yyy` string.
### Configure the CLI
```bash
inkreach-teable auth login --endpoint http://localhost:3003 --token <PAT> --base-id bseXXX
inkreach-teable auth status # verify
```
## Reference Credentials (local dev)
> Never commit real secrets. The reference dev instance uses:
> - ⚠️ The old admin account `hr_admin@xingchen.tech` **no longer exists** (verified 2026-08). Current users: `inkreach@inkreach.pod` (admin) — password: ask maintainer, browser login only.
> - Throwaway accounts for testing can be created via `POST /api/auth/signup` (open on this instance).
> - Base: `bseeZR7yiDNeMPCa4CN` (人事薪资管理) — the test base with ~600 automations.
> - Test table: `tblzPOXQvUruQosKNP5` (has button fields for automation testing).
For CLI use, **always prefer a PAT** over the admin password.
## Permissions
- PAT scope ∩ base role = effective permission. To manage automations you need `automation|read/create/update/delete` scopes **and** a role of editor+ on the base.
- Roles: owner (all), creator (all on the base), editor (read/create/update), commenter (read), viewer (read).
- Workflows auto-create an internal token on activation (name `workflow:{id}`, scope limited to table/record read+write) — used by action nodes, not for CLI auth.
## Troubleshooting Access
| Symptom | Fix |
|---------|-----|
| `401 Unauthorized` | Token wrong/expired → recreate PAT, `auth login` again |
| `403 Forbidden` | Token lacks scope, or base role too low → widen scopes / raise role |
| `404 Base not found` | Wrong base id, or endpoint points to a different instance → `auth status`, check `--base-id` |
| Connection refused | Backend down → health check, restart service (see [development.md](development.md)) |
@@ -0,0 +1,49 @@
# Record Subscriptions
Digest/instant email subscriptions over table records. Full CRUD via
`subscription list/get/create/update/delete/events/send`.
## Commands
```bash
inkreach-teable subscription list
inkreach-teable subscription get <subscriptionId>
inkreach-teable subscription create --name "Daily escalations" --table-id tblXXX [options]
inkreach-teable subscription update <subscriptionId> [options]
inkreach-teable subscription delete <subscriptionId>
inkreach-teable subscription events <subscriptionId>
inkreach-teable subscription send <subscriptionId> # force a digest send now
```
## Options (create/update)
| Option | Meaning |
|--------|---------|
| `--name` | Display name |
| `--table-id` | Target table |
| `--no-watch-created` / `--no-watch-updated` | Exclude create/update events |
| `--field-ids '<json>'` | Restrict watched fields |
| `--record-ids '<json>'` | Restrict watched records |
| `--display-field-id <fld>` | Field shown in the digest row |
| `--attach-csv` / `--no-attach-csv` | Attach matching records as CSV |
| `--filter '<IFilter json>'` | Scope: only records matching this filter |
| `--trigger-on-enter` | Notify only on the transition INTO the filter condition (requires `--filter`); default is state semantics (every matching change) |
| `--send-mode digest\|instant` | Digest (batched on schedule) or instant email |
| `--recipients '<a@x.com,b@y.com>'` | Email recipients |
| `--no-notify-in-app` | Disable in-app notification |
| `--schedule-type daily\|weekly\|hours\|minutes` | Digest schedule kind |
| `--schedule-time HH:mm` | Daily/weekly fire time |
| `--schedule-weekday 0-6` | Weekly day (0 = Sunday) |
| `--schedule-interval n` | Interval for hours/minutes modes |
| `--inactive` | Create/update in inactive state |
## Semantics & gotchas
- `--filter` uses the SAME IFilter shape as view filters. Date values:
`{"mode":"exactDate","exactDate":"2026-08-21","timeZone":"Asia/Shanghai"}`.
User values are id arrays (`"Me"` resolves to the subscription creator).
- Filter fields must be **visible to the creator** (enforced server-side,
fail-closed: unknown operator or deleted field never matches).
- `--trigger-on-enter` implements "notify only when it starts matching"
(e.g. status turns critical); the default state semantics notify on every
matching change.
@@ -0,0 +1,406 @@
# Tables, Fields, Records & Views
Complete guide for building table structures and working with data via the `inkreach-teable` CLI.
## Quick Start
Minimal walkthrough — create a table with fields in one shot, then insert a record:
```bash
# 1. Create a "Tasks" table with 4 fields
inkreach-teable table create --name "Tasks" --base-id bseXXX \
--fields '[
{"name":"Title","type":"singleLineText"},
{"name":"Status","type":"singleSelect","options":{"choices":["Todo","Doing","Done"]}},
{"name":"Due Date","type":"date"},
{"name":"Est Hours","type":"number","options":{"formatting":{"precision":1}}}
]'
# 2. Insert a record
inkreach-teable record create --table-id tblXXX \
--fields '{"Title":"Ship sign-up flow","Status":"Doing","Due Date":"2025-06-15","Est Hours":3.5}'
# 3. Verify
inkreach-teable record get --table-id tblXXX
```
> **Tip**: `table create --fields` creates the table AND all fields atomically. For existing tables, use `field create`.
---
## Tables
### Create
```bash
inkreach-teable table create --name "<name>" [--base-id <id>] \
[--fields '<json>'] [--description <desc>]
```
| Flag | Required | Description |
|------|----------|-------------|
| `--name` | yes | Display name (unique within base) |
| `--base-id` | no | Defaults to stored config |
| `--fields` | no | Array of field objects (see below) |
| `--description` | no | Optional description |
**`--fields` format**: JSON array of `{name, type, [options], [description]}` objects. Types accept full values or aliases (`sel` → `singleSelect`); per-type option shapes and inference rules: [field-types.md](../api-reference/field-types.md).
**Compact shorthand** (in `--fields` only): `"name:type"` string format:
```bash
inkreach-teable table create --name "Projects" --fields '["Name:text","Budget:num","Active:check"]'
```
With select options: `"Status:sel:Todo,Doing,Done"`, `"Tags:mul:Bug,Feature,UI"`
### Update & Delete
```bash
inkreach-teable table update <tableId> --name "<newName>" [--description <d>]
inkreach-teable table delete <tableId>
```
| Operation | Idempotent? | Notes |
|-----------|-------------|-------|
| `update` | yes | Only changes name/description |
| `delete` | **DESTRUCTIVE** | Deletes all records + fields; irreversible |
### List & Get
```bash
inkreach-teable table list [--base-id <id>]
inkreach-teable table get <tableId>
```
`table get` returns `dbTableName` — the internal SQL table name used in `sql` queries (e.g., `SELECT * FROM "bseXXX"."tbl_abc"`).
---
## Fields
### Create
```bash
inkreach-teable field create --table-id <tid> --name "<name>" \
--type <type> [--options '<json>'] [--description <d>]
```
| Flag | Required | Notes |
|------|----------|-------|
| `--table-id` | yes | Target table |
| `--name` | yes | Field display name |
| `--type` | no | Full type name or alias; **omitted → inferred** from `--options` shape or `--name` pattern (rules: [field-types.md § Smart Inference](../api-reference/field-types.md)) |
| `--options` | depends on type | JSON object; select `choices` accept plain strings — see [field-types.md § Option Shapes](../api-reference/field-types.md) |
| `--description` | no | Optional |
**Name resolution**: for `link` fields, use `foreignTableName` instead of `foreignTableId` — the CLI resolves it automatically:
```bash
inkreach-teable field create --table-id tblXXX --name "Project" --type link \
--options '{"foreignTableName":"Projects","relationship":"manyOne"}'
```
### Update & Delete
```bash
inkreach-teable field update <tableId> <fieldIdOrName> [--name "<n>"] [--options '<json>']
inkreach-teable field delete <tableId> <fieldId>
```
| Detail | Rule |
|--------|------|
| `--name` only | Uses the PATCH update API (name/description only) |
| `--options` | Rewrites options via the **convert** API (`PUT …/field/:fid/convert`) — the PATCH update API strips `options` by design. CLI auto-fetches the field's current type; omitted keys are **replaced whole**, not merged |
| `--name` + `--options` | Both applied via one convert call |
| `field delete` | Irreversible — all cell data in that field is lost |
### Inspecting fields & constraints
`field get` and `field visible-choices` accept **fieldId or field name** (the CLI resolves names automatically). Human mode renders select choices with their conditions — no `--json` post-processing needed:
```bash
# One-field overview: which fields carry conditional choices
inkreach-teable field list tblXXX --with-options
# ID NAME TYPE OPTIONS
# ... 处理反馈 singleSelect 21 choices · 19 conditional
# followed by per-choice listings: name [color] + "when: <condition summary>"
# Single field by name — choices expanded with human-readable conditions
inkreach-teable field get tblXXX 处理反馈
# choices (21 · 19 conditional):
# 待工厂补发 [orange]
# when: 问题类型 is 物流问题-超时未揽收 OR 问题类型 is 物流问题-丢件 …
# What a specific record may currently select
inkreach-teable field visible-choices tblXXX recXXX 处理反馈
```
### Select-choice dependency (visibilityFilter) — 2026-08
Single/multiple-select choices may carry a `visibilityFilter` making them visible only when another select field matches:
```json
{"choices":[{"id":"choA","name":"墨盒","visibilityFilter":{
"filterSet":[{"fieldId":"fldDeviceType","operator":"is","value":"choPrinter"}]}}]}
```
- **Read config**: `field get <tableId> <fieldIdOrName>` → `options.choices[].visibilityFilter` (rendered human-readable in default mode; raw JSON with `--json`)
- **Survey a whole table**: `field list <tableId> --with-options` shows a constraint count per field plus per-choice conditions
- **Write config**: `field update --options` (convert API). Backend rejects self-reference (`Self-reference in visibilityFilter is not allowed`) and dependency cycles (`Dependency cycle detected…`) with 400 — enforced on both V1 and V2 paths since fix `26ed1f732`
- **Evaluate for a record**: `field visible-choices <tableId> <recordId> <fieldIdOrName>` → choices currently selectable
- **Write-time enforcement**: writing a record value that fails its visibility condition → 400 `Option "X" does not meet the current row conditions and cannot be selected.`
---
## Records
### Get (read)
```bash
# All records (paginated)
inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] \
[--page-size <n>] [--page <n>] [--search <kw>]
# Single record by ID
inkreach-teable record get-one <recordId>
```
| Flag | Description |
|------|-------------|
| `--filter` | Filter expression: `{"fieldId":{"operator":"value"}}` |
| `--projection` | JSON array of field IDs to return: `'["fldXXX","fldYYY"]'` |
| `--page-size` | Records per page (default 100) |
| `--search` | Keyword search: `"keyword"` (auto-wraps to `{"value":"keyword"}`) |
| `--view-id` | Fetch in view order with view's filter/sort/group applied |
**Filter operators by field type**:
| Field Type | Supported Operators |
|-----------|-------------------|
| `singleLineText` `longText` `url` `email` `phone` | `is` `isNot` `contains` `doesNotContain` `isEmpty` `isNotEmpty` |
| `number` `currency` `percent` `duration` `rating` | `is` `isNot` `isGreater` `isGreaterEqual` `isLess` `isLessEqual` `isEmpty` `isNotEmpty` |
| `checkbox` | `is` |
| `date` `createdTime` `lastModifiedTime` | `is` `isNot` `isWithIn` `isBefore` `isAfter` `isOnOrBefore` `isOnOrAfter` `isEmpty` `isNotEmpty` |
| `singleSelect` (single) | `is` `isNot` `isAnyOf` `isNoneOf` `isEmpty` `isNotEmpty` |
| `singleSelect` (multi) | `hasAnyOf` `hasAllOf` `isExactly` `isNotExactly` `hasNoneOf` `isEmpty` `isNotEmpty` |
| `multipleSelect` | `hasAnyOf` `hasAllOf` `isExactly` `isNotExactly` `hasNoneOf` `isEmpty` `isNotEmpty` |
| `user` `createdBy` `lastModifiedBy` (single) | `is` `isNot` `isAnyOf` `isNoneOf` `isEmpty` `isNotEmpty` |
| `user` `createdBy` `lastModifiedBy` (multi) | `hasAnyOf` `hasAllOf` `isExactly` `hasNoneOf` `isNotExactly` `isEmpty` `isNotEmpty` |
| `link` (single) | `is` `isNot` `isAnyOf` `isNoneOf` `contains` `doesNotContain` `isEmpty` `isNotEmpty` |
| `link` (multi) | `hasAnyOf` `hasAllOf` `isExactly` `hasNoneOf` `isNotExactly` `contains` `doesNotContain` `isEmpty` `isNotEmpty` |
| `attachment` | `isEmpty` `isNotEmpty` |
> **⚠️ `isAnyOf` only works for select/link/user fields, NOT for text/number fields.** For text fields with multiple values, use `or` conjunction with multiple `is` rules:
> ```json
> {"conjunction":"or","filterSet":[
> {"fieldId":"fldX","operator":"is","value":"val1"},
> {"fieldId":"fldX","operator":"is","value":"val2"}
> ]}
> ```
**Date sub-operators** (with `is` / `isNot` / `isWithIn` / `isBefore` etc.):
| Operator | Description |
|----------|-------------|
| `today` `tomorrow` `yesterday` | Relative day |
| `currentWeek` `lastWeek` `nextWeekPeriod` | Relative week |
| `currentMonth` `lastMonth` `nextMonthPeriod` | Relative month |
| `currentYear` `lastYear` `nextYearPeriod` | Relative year |
| `oneWeekAgo` `oneWeekFromNow` | ±1 week |
| `oneMonthAgo` `oneMonthFromNow` | ±1 month |
| `daysAgo` `daysFromNow` | ±N days (set `value`) |
| `exactDate` | Specific date: `"2025-06-15"` |
| `exactDateTime` | Specific datetime: `"2025-06-15T09:00:00.000Z"` |
| `pastWeek` `pastMonth` `pastYear` | Last week/month/year (for `isWithIn`) |
| `nextWeek` `nextMonth` `nextYear` | Next week/month/year (for `isWithIn`) |
| `pastNumberOfDays` `nextNumberOfDays` | ±N days (for `isWithIn`, set `value`) |
**Conjunction**: `"and"` (all must match) or `"or"` (any matches). Filter sets can nest for complex logic.
### Create
```bash
# Single record
inkreach-teable record create --table-id <tid> --fields '<json>'
# Batch (up to 2000)
inkreach-teable record create --table-id <tid> --records '<json>'
```
**`--fields` format** (single): `{"FieldName":"value","Another":"value"}` — field names resolve automatically.
**Value semantics**:
| Value | Meaning |
|-------|---------|
| `"string"` | Normal value |
| `null` | **Clear** the cell |
| `""` (empty string) | **Skip** (no change; update only) |
> **Important**: `""` is skip, not clear. Use `null` to empty a cell. For checkbox: `true` = checked, `null` = unchecked (NOT `false`).
**`--typecast`**: auto-converts human-readable values to internal IDs. Essential for `user` and `link` fields:
```bash
# Without typecast — must use IDs
inkreach-teable record create --table-id tblXXX --fields '{"Assignee":{"id":"usrXXX","title":"Alice"}}'
# With typecast — use display names
inkreach-teable record create --table-id tblXXX --fields '{"Assignee":"Alice"}' --typecast
```
**`--records` format** (batch): `{"header":["Field1","Field2"],"records":[["val1","val2"],["val3","val4"]]}`
### Update
```bash
inkreach-teable record update --table-id <tid> --records '<json>'
```
**`--records` format**: `[{"id":"recXXX","fields":{"FieldName":"newValue"}}]` — array of `{id, fields}` objects. Batch up to 2000.
**Record reordering**: pass `--order` with `viewId`, `anchorId`, `position`:
```bash
inkreach-teable record update --table-id tblXXX \
--records '[{"id":"recXXX","fields":{}}]' \
--order '{"viewId":"viwXXX","anchorId":"recYYY","position":"before"}'
```
### Delete
```bash
inkreach-teable record delete --table-id <tid> --record-ids "recA,recB,recC"
```
Max 1000 IDs per call. Irreversible.
---
## Views
### List & Get
```bash
inkreach-teable view list <tableId>
inkreach-teable view get <viewId>
```
### Create
```bash
inkreach-teable view create --table-id <tid> --name "<name>" [--type <t>]
```
| `--type` | Description |
|----------|-------------|
| `grid` | Default spreadsheet view |
| `kanban` | Board (requires a singleSelect/link grouping field) |
| `form` | Form input view |
| `gallery` | Card gallery |
| `calendar` | Calendar (requires a date field for grouping) |
### Update (configure filters, sorts, grouping)
```bash
inkreach-teable view update <viewId> \
[--name "<n>"] \
[--filter '<json>'] \
[--sort '<json>'] \
[--group '<json>'] \
[--column-meta '<json>']
```
**Filters**: `{"conjunction":"and","filterSet":[{"fieldId":"fldXXX","operator":"is","value":"Done"}]}`
**Sorts**: `[{"fieldId":"fldXXX","order":"asc"}]`
**Group**: `[{"fieldId":"fldXXX","order":"asc"}]`
**Column meta**: controls visibility, width, order of fields in the view.
### Delete
```bash
inkreach-teable view delete <viewId>
```
Deleting a view does NOT delete the table or its data — only the view configuration.
---
## View Presets
Presets are **view-scoped, named bundles of view configuration** (filter/sort/group, optionally options/columnMeta), stored server-side and shared with all collaborators — each view owns its own preset list. Users apply them from the toolbar preset dropdown (labeled by current state: 公共 / 个人 / active preset name; the old "个人" toggle button was merged into this dropdown). Applying seeds the user's personal-view overlay (persisted server-side per user per view), so operators can work in different presets simultaneously without touching the collaborative view. Managing presets requires table-level view-update permission.
### Typical use
One-click screening configs for operators, e.g. "pending orders" vs "in-progress orders" on an order table, without granting view-edit permission.
### Commands
```bash
# preset screening pending orders (bound to one view)
inkreach-teable preset create --table-id tblXxx --view-id viwXxx --name "待处理订单" \
--filter '{"conjunction":"and","filterSet":[{"fieldId":"fldStatus","operator":"is","value":"待处理"}]}'
# preset for in-progress orders, newest first
inkreach-teable preset create --table-id tblXxx --view-id viwXxx --name "正在处理的订单" \
--filter '{"conjunction":"and","filterSet":[{"fieldId":"fldStatus","operator":"is","value":"处理中"}]}' \
--sort '{"sortObjs":[{"fieldId":"fldCreated","order":"desc"}]}'
inkreach-teable preset list tblXxx --view-id viwXxx
inkreach-teable preset update <presetId> --table-id tblXxx --name "新名字" # or replace --filter/--sort/...
inkreach-teable preset delete <presetId> --table-id tblXxx
```
### Semantics
- Config keys you do NOT pass stay **unset**: on apply, the preset keeps the view's current value for those keys (a filter-only preset does not touch the sort).
- Keys you DO pass **replace** that aspect on apply.
- `--column-meta` snapshots field visibility/order — avoid unless needed: fields added later stay hidden while that preset is active (stale snapshot).
- Deleting a preset does not touch views or records; users applying it fall back to the public config on next switch.
- REST equivalents: `GET/POST /api/table/:tableId/view-preset` (list accepts `?viewId=`; create requires `viewId`), `PUT/DELETE /api/table/:tableId/view-preset/:presetId` (writes need view-update permission).
- Per-user personal config: `GET/PUT/DELETE /api/table/:tableId/view-preset/personal?viewId=` (view-read permission; PUT body `{config, enabled}`) — this is what the UI "个人配置" entry persists.
---
## Value Formats Quick Reference
| Field Type | Value format | Notes |
|-----------|-------------|-------|
| `singleLineText` / `longText` | `"string"` | |
| `number` | `42` or `3.14` | Integer or float |
| `checkbox` | `true` / `null` | NOT `false` for unchecked |
| `date` | `"2025-06-15"` or `"2025-06-15T09:00:00.000Z"` | ISO 8601; `"now"` maps to current time |
| `singleSelect` | `"Option Name"` | (string) |
| `multipleSelect` | `["Tag A","Tag B"]` | (array of strings) |
| `rating` | `3` | Integer 1..max |
| `attachment` | `[{"name":"f.png","token":"xxx"}]` | Always array of objects; update **replaces all** |
| `user` | `{"id":"usrXXX","title":"Alice"}` | With `--typecast`: just `"Alice"` or `"alice@example.com"` |
| `link` | `[{"id":"recXXX"}]` | With `--typecast`: just `"Linked Record Name"` |
| `formula` / `rollup` / `lookup` | Read-only | Cannot be set via record create/update |
| `autoNumber` / `createdBy` / `createdTime` / `lastModifiedTime` / `lastModifiedBy` | Auto-populated | Cannot be set manually |
| `button` | `{"count":0}` | Read-only; incremented by button clicks |
---
## Common Patterns
| User intent | Correct approach | Wrong approach |
|-------------|-----------------|----------------|
| Create a table with fields | `table create --fields '[...]'` — one call | Multiple `field create` calls (slower, more error-prone) |
| Use display names for linked records | `record create --fields '{"Project":"Acme"}' --typecast` | Manually looking up `recXXX` IDs |
| Use email to assign user | `record create --typecast --fields '{"Assignee":"alice@co.com"}'` | Looking up `usrXXX` IDs |
| Clear a cell | `null` in the fields object | `""` (which means skip!) |
| Update one field, leave others | `--records '[{"id":"recXXX","fields":{"Status":"Done"}}]'` | Re-sending all fields (unnecessary, slower) |
| Fetch only 2 columns | `record get --projection '["fldXXX","fldYYY"]'` | Fetching all fields (wastes bandwidth) |
| Find records by keyword | `record get --search "keyword"` | `record get` then filtering manually |
| Add a select field with colored options | `--options '{"choices":["A","B"]}'` — colors auto | Manually specifying color for every choice |
| Make a link field to another table | `--type link --options '{"foreignTableName":"Target","relationship":"manyOne"}'` | Using IDs + manual lookup |
| Reorder records in a view | `record update --order '{"viewId":...,"anchorId":...,"position":"before"}'` | Deleting and re-creating records |
| View data in kanban board | `view create --type kanban` then `view update --group` | Creating special columns |
| Set datetime in a date field | `"2025-06-15T14:30:00.000Z"` | `"2025-06-15 14:30"` (non-ISO; may be rejected) |
| Update attachments | Always pass the full array (replaces all) | Trying to add/remove individual attachments |
| Batch insert 500 records | One `record create --records` call | 500 separate `record create` calls |