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
- 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).
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
The records API requires search[]=value (tuple), not search=value
(string). Without this, the CLI returned 400 'expected tuple, received
string'. Same fix already exists in the workflow getRecords action.
The API controller reads recordIds from @Query(), not @Body.
CLI was sending { records: ids } as JSON body, causing:
'expected array, received undefined at "recordIds"'
Fixed: rename deleteRecord→deleteRecords, send recordIds as
repeated query params (?recordIds=rec1&recordIds=rec2).