Cross-diff of all @Controller routes vs docs-json (486 vs 438 after shape
normalization): real undocumented routes include the entire authority-matrix
family (live 200, 0 doc entries), select-visibility, plugin runtime/proxy,
ShareDB socket internals, oauth-server, health; ~27 doc entries belong to
the enterprise backend and 404 on this CE instance. Completeness check =
controller grep, docs-json alone is not exhaustive.
Verified live: https://table.inkreach.cc/docs-json and /docs → 404
(frontend doesn't proxy swagger paths), while /api/* IS proxied
(PAT auth → 200). Guide now: two-origin table (domain for API calls,
backend origin for swagger), ssh-tunnel fallback for remote machines,
backend-controller grep as last resort; instance-access.md connection
table gains the public-domain row.
Audit vs live swagger: guide covers the CLI-mirrored operational core;
added verified aggregation/row-count + a full inventory section pointing
to the unauthenticated /docs-json spec for the ~35 uncovered groups
(auth/session, user, spaces, share, attachments, import/export, trash,
templates, org/oauth/admin/billing, official webhooks, plugin UI…).
Comprehensive review found the skill taught CLI-only usage (just one
script-action callback snippet); nothing on calling the API directly.
New guide (verified against the live instance):
- base URL + Bearer PAT auth, Node v24 fetch header-drop warning
- full endpoint map mirrored from the CLI's HTTP layer (records/fields/
tables/views/presets/workflow/secrets/executions/fire/sql/PAT/signup)
- canonical curl examples incl. typecast/fieldKeyType bodies, url-encoded
filter, convert-for-options, workflow node PUT + activate, script-action
callback via triggerInput._apiBaseUrl
- gotchas: typecast never creates choices (silent no-op), visibilityFilter
400, PATCH field strips options, whole-graph node replace, search[] tuple
SKILL.md: module table row + routing rule wired to the new guide.
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.
- untrack dist/, add .gitignore, add pnpm-lock.yaml
- inline tsconfig.base.json options (no monorepo extends)
- install.sh: CLI now at repo root; install skill to ~/.zcode/skills (user scope)
- fix stale paths in skill docs; 7 actions in help text; README
One user edit cascaded into 3-5 workflow executions and hourly sync jobs
generated 1000+ wasted runs/week (7d: 15.7k of 18k executions of the
ticket-status workflow ended in skipped no-op guards). Root cause: workflow
scripts write records via REST with a runner-signed token, and record-event
triggers could not distinguish those writes from human edits — every script
PATCH re-triggered its own workflow.
Attribution chain (new):
- WorkflowTokenService signs {workflowId, executionId} into the runner token
- jwt.strategy lifts it into CLS workflowContext (User-type branch)
- all 13 automation.record.* emit sites tag payloads with writeSource
- the 4 record triggers drop SELF echoes (writeSource.workflowId === own id)
unconditionally; cross-workflow writes keep firing by default (chaining is
load-bearing: order-sync建档 -> status workflow stamps 待处理, ~142 real
actions/week). Opt-in stronger dedup via trigger config
skipWorkflowWrites: true.
Verified live: human edit (处理反馈=正在核实) -> exactly 1 execution, script
flipped 流转状态 待处理->处理中, zero follow-up echoes; restore writes likewise
1 execution each with skipped no-ops. Unit: 14 new tests, 318 adjacent green.
Lands the view-preset / server-persisted personal-layer feature that has
been running in production, plus the hotfix for "the same preset shows
different filter configs to different users" (物流问题-已出轨迹 on
tblgsJTXhqwiDpsjeL5 / viwyszJmLQNOLhA9sh5).
Root cause of the divergence:
- usePersonalViewSync implicitly flushed any overlay edit by a
view|update user INTO the shared preset row (last-writer-wins). Overlays
are stale snapshots seeded at apply time, so one user's seed silently
erased colleagues' filter changes (2026-09-17 02:55 the preset lost the
流转角色=售后 condition), and active overlays never converged because
preset updates do not propagate.
Fix (semantics: presets are shared definitions, edits are personal):
- fork-on-edit: the first user edit strips the seed markers from the
overlay (toolbar switches to the personal config); edits only ever
write the user's personal row
- shared presets change ONLY through the new explicit, confirmed
"Update to current config" row action (canManage users)
- un-edited preset overlays auto-follow redefinitions: applyPreset stamps
a presetVersion (preset.lastModifiedTime); when the preset list
refetches with a newer row the overlay re-seeds. Edited overlays carry
no markers and are never clobbered. Legacy rows without a version
re-seed exactly once
- exits strip both seed markers from the persisted personal row
Also lands the pending WIP shipped with the deployed build: select-choice
visibility constraint, personal-view server sync + proxy, grid async
records scope/request tracking, CLI resource display, preset i18n (10
locales), view_preset migrations, docs/skill updates, .zcode gitignore.
Verified end-to-end on the live instance (headless chromium, real
session): apply -> edit flips toolbar to Personal with ZERO shared-row
PUTs; explicit update PUTs the shared row; API-side redefinition
auto-reseeds an un-edited overlay on the next list refetch.
- inkreach-cli: subscription command in bin, built dist
- env.development: public origin, plugin service registry, cache
provider=redis, cold retention days
- skill docs: subscription quick reference, deploy and ops notes
- Full subscription CRUD + digest/instant email scheduling
- filter-evaluator delegates to core evaluateFilterItem (all operators +
date sub-modes with timezone, value normalization, fail-closed)
- triggerOnEnter: notify only on the transition INTO the filter condition
- filter field visibility validated on create/update
- CLI: subscription list/get/create/update/delete/events/send
Parse LAST_MODIFIED_TIME([Name]/{fldId}) references into trackedFieldIds so
the modification time only resets when a referenced field changes.
- core: relax expression from z.literal('LAST_MODIFIED_TIME()') to z.string(),
add parseLastModifiedTimeReferences() + spec
- v1 field-supplement.service: resolve trackedFieldIds from expression on
create/convert (incl. same-type merge), 400 on unknown field name
- v2 field-open-api-v2.service: resolve trackedFieldIds from expression on
create/createFields/convert
- docs: field-types.md tracked-field reference section
Investigation of the 'grid edits bypass the REST layer' report found the
ShareDB socket submit path is dead code (adapter db.commit() throws and no
frontend submitOp callsites exist) — grid cell edits already flow through
record.updateCell -> REST updateRecord and DO fire triggers. The real
coverage gaps were the selection-layer writes that call the open-api
SERVICES below the controller where the manual emits live:
- v1 selection paste/paste-by-id, clear/clear-by-id (grid paste, Delete-key
clear): emitted nothing. Now emit automation.record.create/update with
full post-write records + changedFieldIds/oldFields diffs (best-effort —
emission failure logs but never fails the write).
- v1 record duplicate: emitted nothing. Now emits automation.record.create.
- v2 paste endpoints (range paste + paste-stream + paste-by-id-stream):
returned only counts. PasteResult/PasteStreamDoneEvent now carry per-record
update diffs and created-record values through to the nest layer, which
emits create/update events (full record + diff metadata).
- v2 record duplicate: now emits automation.record.create.
Form submissions intentionally remain formSubmitted-only (independent
channel); deletions have no trigger category. Docs corrected: grid cell
edits fire triggers (they are REST), coverage table updated.
Verified live: duplicate / range paste / stream paste on a v2 base each
produced a workflow execution with full triggerInput; selection e2e
114/114, PasteHandler specs 30/30, selection unit 31/31.
The backend never compiles @teable/* sources — it loads packages/*/dist via
node_modules symlinks (@teable/v2-core is a webpack external, read at runtime
from packages/v2/core/dist/index.cjs). Editing a package's src and only
rebuilding apps/nestjs-backend ships nothing: no error, old behavior online.
Documented the correct build order + verification greps in AGENTS.md and the
skill's development guide (found while shipping the defaultValue cascade fix).
Wrapping --config in {"config":{...}} deploys a silent empty script
(succeeds in ~1ms, result {}). Document the trap + verification recipe
(workflow get --json, nodes[].config.code non-empty).
Engine gap: the 'automation.record.update' event carries changedFieldIds
(and now oldFields), but RecordUpdatedTrigger.accumulate() dropped both —
scripts had no way to see what changed or pre-update values, despite the
documented trigger output (trigger-types.md).
- record-updated/record-condition/record-created-or-updated triggers:
pass changedFieldIds + oldFields through to accumulate()
- batch-buffer TriggerEvent += oldFields; batch flush records[] entries
now carry changedFieldIds + oldFields per record
- v2 updateRecord/updateRecords: emit oldFields (pre-update snapshot)
- v1 controller updateRecord/updateRecords: snapshot before-fields via
getRecordsById, compute changedFieldIds (deep-equal diff, same
semantics as v2) instead of hardcoded [] — v1/v2 parity
- new spec record-updated.trigger.spec.ts (4 tests, passthrough +
watch-filter + table isolation); workflow suite 16 files / 99 tests
- trigger-types.md: document semantics + honest scope (REST API writes;
ShareDB grid edits do not fire this trigger)
Live-verified: PATCH 流转角色 → trigger_input.changedFieldIds=[fld...],
oldFields=[售前客服] → record=[技术]; script reads oldRole via
triggerInput; fieldIds watch-filter blocks non-watched fields.
commander stores --code-file as opts.codeFile (kebab keys are never set),
so 'action update --code-file' silently no-opped (workflow.ts read
opts['code-file']) and 'auth token create --base-ids/--full-access'
never applied (auth.ts read the kebab keys). The 'action add' path was
unaffected because buildActionConfig's pick() checks both forms.
- workflow.ts action update: opts['code-file'] ?? opts.codeFile
- auth.ts token create: baseIds/fullAccess dual-form read
- verified live: action update --code-file now persists the new script
(was silently keeping old code); commander dual-key behavior confirmed
by direct parse (kebab keys absent, camelCase present, and this
commander version drops the --base-ids VALUE entirely — the dual read
covers both)
- cli-reference.md: field get/update/delete now <tableId> <fieldId>;
new field visible-choices command; note --options goes via convert API
- tables-fields.md: correct update/delete signatures; options-rewrite
semantics (convert API, whole-replace not merge); new section on
select-choice visibilityFilter dependency (read/write/evaluate,
backend 400 guards, write-time enforcement message)
- FieldOpenApiV2Service.convertField now calls the shared V1 validator
(assertSelectVisibilityConfigValid) before executing the update command.
Previously only the V1 path validated self-reference / dependency cycles,
so invalid configs could be persisted through V2 (verified live: a
self-referencing choice was accepted with 200).
- V1 assertSelectVisibilityConfigValid private -> public (shared guard).
- Unit tests: 3 new cases in field-open-api-v2.service.spec.ts (guard
invoked with exact args; rejects before executing when validator throws;
called with raw type/options for non-select conversions). 63/63 pass;
tsc build config 0 errors.
- inkreach-cli: field get/update/delete targeted non-existent /api/field/:id
routes; now use /api/table/:tableId/field/:fieldId. update --options goes
through the convert API (the PATCH update API strips options by design).
New: convertField + getVisibleChoices client methods, field visible-choices
command (evaluates choice visibility for a record).
getEffectiveConfig was rewritten in 948a20b88 to fetch all configs via
findMany (Prisma cannot reliably WHERE on nullable String? with ''), but
the spec still mocked the old findFirst call chain — the table-override
test silently fell back to defaults. Mocks now return the config array.
Bundle plugins run inside the base page (not a sandboxed iframe), so their
declared apiOrigins must be in the PAGE's connect-src. Previously the page
CSP was hardcoded static — plugins connecting to new APIs required a code
change + rebuild.
- GET /api/plugin/csp-origins: union of all plugins' manifest apiOrigins
- [[...slug]].tsx getServerSideProps: fetches origins via ssrApi, merges
with static fallbacks ('self' + 8001 hosts), builds CSP dynamically
- PUT /api/plugin/:pluginId accepts apiOrigins (merged into manifest JSON)
- CLI: plugin csp list / plugin csp set <id> --origins '[...]'
- @DisabledPermission on plugin GET/PUT routes (default-deny guard blocked
PAT auth); ownership still enforced via manageablePluginCreatedBy()
- CLI: plugin dev list/allow/revoke (userId or email)
- Backend: PluginDeveloperService.addDeveloper is now idempotent
(returns existing record instead of unique-constraint 500)
- Backend: removeDeveloper silently no-ops on P2025 (not found)
- Controller: route api/plugin-developers (avoids :pluginId collision),
@DisabledPermission so admin check is the sole gate
Scripts can now branch on WHO triggered the workflow and their role in the
base, without an extra API call (the workflow token has no role-read scope).
New triggerInput fields (user-initiated triggers only: buttonClick /
recordCreated / recordUpdated / recordCondition / formSubmitted):
- _triggerUserRoles: string[] (enabled role display names in this base)
- _triggerUserIsAdmin: boolean (space owner / AM admin / AM-disabled)
Absent for scheduledTime / webhook (no user context).
Implementation:
- trigger-user-info.ts: resolveTriggerUserInfo(prisma, baseId, userId) —
standalone, unit-tested (8 tests). Mirrors the admin logic in
AuthorityMatrixPermissionService but returns only role names + bool.
- workflow-runner.service.ts: resolve once at first action node, merge into
enrichedInput (both execute() and test() paths).
- skill action-types.md: document the fields + script branching example.