The setter updater contract previously said "must be pure" only in the sense of "don't mutate prev / return a new value" (the isEqual short-circuit footgun); it did not cover side effects inside the updater.
Document that updaters must also be side-effect-free: don't smuggle a derived value out (e.g. into an enclosing-scope variable) to drive post-write work, and don't rely on how often or when the updater runs. To react to what changed, derive it from the value transition in a useEffect that watches the value.
Deliberately does not promise single synchronous invocation, to keep the setter free to batch/retry/defer later. Updates the CacheSetStateAction type doc, the useCache @remarks (canonical reference for all three hooks), and cache-usage.md.
useCache, useSharedCache and usePersistCache setters now accept a React-style functional updater `(prev) => next` in addition to a concrete value. The updater resolves `prev` from the latest stored value at write time rather than the render-time snapshot, making read-modify-write correct across an `await` — the root cause of the keep-alive overwrite race behind #16460.
`prev` is typed shallow-readonly (`ReadonlyValue<T>`), so mutating it in place and returning the same reference — which the CacheService `isEqual` short-circuit would otherwise swallow silently — is a compile error. Concrete-value calls are unchanged, so existing consumers keep compiling.
The renderer useCache mock mirrors the functional branch with the same default fallback; docs and hook tests updated. Consumer call-site adoption lands separately.
Migrate window-bounds persistence off the electron-window-state library into
a WindowManager built-in `rememberBounds` capability, backed by the main-process
persist cache (`window.bounds` key — its first real consumer).
- New `windowBoundsTracker` free-function module: validates the stored record
(including displayBounds), restores onto the display the window was last on
(clamping into its work area, never resetting to primary), and snapshots at
teardown via getNormalBounds + isMaximized.
- Singleton-only gate (dev warning for non-singleton types). Runtime toggle
`wm.setRememberBounds` (orthogonal to the registry flag; OFF drops only that
type's slot) plus `wm.peekWindowBounds`.
- Persist at three teardown exits: native close (singletons), before
window.destroy() in destroyWindow (programmatic destroys), and a new onStop
so shutdown writes land before CacheService flushes its persist map.
- Wire Main + QuickAssistant. Main re-applies maximize consumer-side on its own
show schedule (tray-on-launch defers to first show); remove electron-window-state
and its orphaned keepers/constants/comments.
Fullscreen is not persisted and old *-state.json is not migrated (one-time
reset, loseable). Adds tracker/integration/persist tests, extends the main
CacheService mock with persist methods, and documents the capability plus a
breaking-change note.
Make the main-authoritative and renderer persist tiers express presence as
deviation from the schema default rather than backing-store membership, extend
the API symmetrically, and unify the debounced-write cadence.
- hasPersist now reports whether the effective value differs from the schema
default (main + renderer). loadPersist/loadPersistCache seed every key, so the
old Map-membership check was always true and carried no information.
- Add deletePersist (reset-to-default) on both tiers; delegates to setPersist so
it inherits the same-value no-op, subscriber notify, and renderer cross-window
broadcast.
- Add subscribePersistChange on the main tier for in-main consumers, mirroring
subscribeChange (main-local, never relayed to renderers). setPersist now
notifies persist subscribers on actual change.
- Align persist debounce to 350ms (main + renderer) and BootConfig save debounce
to 350ms for more coalescing of paused/bursty writes; extract the renderer
magic number into a named constant.
- Normalize CacheService comments to JSDoc on core methods and document the
default-relative persist semantics; update cache-overview.md.
Add an independent, main-authoritative persist tier to the main-process
CacheService, stored as a JSON file at {userData}/cache.json. It is inline
(mirroring the renderer persist structure), fixed-keys-only, with no delete
or TTL; values are loseable and fall back to schema defaults on miss.
Writes are debounced (200ms) and flushed atomically (temp file + rename),
with a flush on service stop. Unknown/stale keys in the file are pruned on
load to keep the fixed-keys contract. The renderer persist IPC relay is
untouched: Main cannot read renderer persist and vice versa.
This phase is architecture-only: the schema ships a single scaffold key
(internal.persist_probe) and no business consumer; window-state and other
consumers will follow. Docs under docs/references/data and CLAUDE.md are
updated to distinguish the new Main persist store from the relay.
Pagination docs were scattered across api-types.md (types + cursor
semantics), data-api-in-renderer.md (hooks), data-api-in-main.md (offset
example + keyset note), api-design-guidelines.md (query params), and
data-ordering-guide.md (cache shapes + determinism), with no single
discoverable home for the offset-vs-cursor model.
Add docs/references/data/data-pagination-guide.md as the canonical hub
(mirrors data-ordering-guide.md): two modes, four-layer quickstart, wire
contract, server impl (offset + keyset cursor + multi-band caveat),
renderer consumption, FTS pagination, gotchas, and a see-also map. Other
docs keep their authoritative slice and link to the guide; the migrated
conceptual prose is removed from api-types.md to avoid duplication.
Also fix two pre-existing broken anchors found while verifying links
(database-patterns withWriteTx; ordering guide section number).
Consolidate the per-service <key>:<id> cursor codec and the keyset
WHERE/ORDER BY tuple into services/utils/keysetCursor.ts. keysetOrdering
derives the WHERE predicate and the matching ORDER BY from one direction
spec, so the two cannot drift apart and silently skip/repeat rows.
Migrate TranslateHistory, AgentSession, AgentSessionMessage (list), and
Painting to the shared util; delegate ftsSearch's codec to parseCursor /
encodeCursor while keeping its 422-throw policy. Harden Painting's cursor
from a single key to a defensive (orderKey, id) tuple.
`.ts` files under `src/shared` must use camelCase (naming-conventions §3.2);
kebab-case is only sanctioned under `packages/ui/` and `src/renderer/routes/`.
The `presets/` kebab naming came from best-practice-layered-preset-pattern.md,
which predated and conflicted with the authoritative spec.
- Rename presets/{code-cli,default-assistant,file-processing,mini-apps,
translate-languages,web-search-providers}.ts and utils/code-languages.ts
(plus the two matching __tests__ files) to camelCase, and update all importers
- Fix the upstream generator scripts/update-languages.ts to emit
codeLanguages.ts; otherwise `pnpm update:languages` would recreate the
kebab-named file
- Correct best-practice-layered-preset-pattern.md (kebab -> camelCase) and link
it to naming-conventions §3.2 so it cannot drift again
- Fix two stale `types/file` path references in file/architecture.md
Add docs/references/data/database-construction.md as the single home for how the SQLite DB is built and evolved: boot init order, drizzle migrations (regenerate-never-rename, CI gates, additive-vs-rebuild), the CUSTOM_SQL_STATEMENTS every-boot replay (~0.1ms O(1)), and the FTS5 fts_rowid rowid-stability rule, plus a gotchas table. Move the Migrations and Custom SQL sections out of database-patterns.md into it (left as pointers), and index it from data/README.md and src/main/data/db/README.md.
Fix stale references found while consolidating: wrong generate command, customSql.ts vs customSqls.ts, columnHelpers.ts vs _columnHelpers.ts, a nonexistent messageFts.ts, yarn vs pnpm, the v2-todo single-0000 claim, the generated-column wording, and v1 data.blocks vocabulary in the testing doc.
Replace the `await import(...)` cycle-breaking hack with a lightweight, type-safe service locator. Participating services self-register and resolve siblings lazily through `getDataService(...)`; the registry imports services only as `import type`, so it stays a sink in the import graph and no value cycle can form. Only the services in a real cycle join the registry.
- add dataServiceRegistry (register/get) + symmetric migration of the two cycles: Message<->Topic, Provider<->ProviderRegistry
- tests load the sibling via a side-effect import so it self-registers
- document the pattern in data-api-in-main.md and a new services/README.md
Add executionPolicy ('run-on-change' default | 'bootstrap-only') to ISeeder
and a seedRunner:bootstrapCompleted marker written after the first fully-
successful seeding pass. SeedRunner skips bootstrap-only seeders once the
window closes, replacing DefaultAssistantSeeder's hand-rolled seed-journal
guard; SEED_KEY_PREFIX returns to a private constant and the cross-seeder
name export is removed.
Resolve the spec conflict between api-design-guidelines.md (orderBy +
order) and api-types.md / SortParams (sortBy + sortOrder) by adopting
sortBy + sortOrder everywhere:
- api-design-guidelines.md: align the sorting convention with
SortParams and cross-link api-types.md
- ListOptions: extend SortParams; rename the direction field
orderBy -> sortOrder
- AgentService + tests: follow the ListOptions rename
- renderer types/agent.ts: drop the unused, drifted ListOptions copy
DataApi handlers and their services may perform only SQLite reads/writes via Drizzle; fs/network/process/external-service side effects are prohibited regardless of nesting depth or an accompanying DB write. Add a Hard Rule section to api-design-guidelines, scope-limit service domain workflows to DB I/O, and echo the boundary in the overview and README.
Apply the naming-conventions §6.1 acronym-casing rule (MCP -> Mcp) to the
MCP* PascalCase identifier family across the codebase (McpServer, McpTool,
McpToolResponse, BuiltinMcpServerNames, McpService, etc.) plus the local
identifiers boundMcp/enableMcp/disableMcp and the didiMcp registry key.
Regenerate the OpenAPI spec from the renamed schemas.
Deliberately left unchanged (not naming-convention identifiers): persisted
field keys read by migrators (enabledMCPs), v1 Redux selectors (selectMCP),
string values (ExaMCP, logger labels), and UPPER_SNAKE constants (MCP_*).
Also fix naming issues in the data reference docs that prompted this:
- JSONStreamReader -> JsonStreamReader (match the real class name)
- rowToMCPServer -> rowToMcpServer (match the real function name)
- replace the TopicService getInstance() skeleton with a direct singleton
- sync stale MCPServer/MCPTool/McpService references in affected docs
The V2 migration disabled foreign keys ad-hoc in each migrator, which was fragile (a
one-shot PRAGMA is lost at @libsql/client's post-transaction connection swap) and produced
recurring FK constraint bugs.
Register foreign_keys = OFF once via the patched setPragma() in MigrationDbService so it
replays on every connection, and remove the scattered per-migrator PRAGMA toggling. Each
migrator now self-checks its own fully-resolved tables via the new
BaseMigrator.assertOwnedForeignKeys() (scoped PRAGMA foreign_key_check per table), with the
engine's final verifyForeignKeys() as the cross-domain backstop.
- BaseMigrator: add assertOwnedForeignKeys() helper + real-DB tests
- MigrationDbService: db.run(PRAGMA OFF) -> client.setPragma(PRAGMA OFF)
- Assistant/Chat/Agents/Knowledge migrators: drop FK toggling, add scoped self-checks
- remapAgentPrefixIds: drop FK toggling + whole-db check; keep manual tx (ATTACH window);
export AGENT_TABLES for AgentsMigrator self-check
- pragmaReplay: add foreign_keys replay tests
- docs: update v2-migration-guide + migration README FK conventions
Move all renderer source from src/renderer/src/* up one level to
src/renderer/*, removing the redundant nested src directory.
- Update path aliases (@renderer, @types, @logger, @data) and TanStack
Router paths in electron.vite.config.ts; update tsconfig.{json,web,node}
path mappings and include globs.
- Fix Vite root-relative script paths in the 8 renderer HTML entries.
- Update cross-process relative imports in main/preload (language,
apiServer models, preload index) to drop the /src segment.
- Switch renderer test imports of the logger mock to the @test-mocks alias.
- Update hardcoded renderer paths in scripts and their fixtures, lint
configs (eslint/oxlint/biome), CODEOWNERS, docs, and the data-classify tool.
- Convert deep (../../+) relative imports within the renderer to the
@renderer alias (69 files, 108 imports); keep single-level relatives.
- Fix doc links broken by the move and correct one pre-existing broken
link in naming-conventions.md.
packages/shared was never a real pnpm workspace package (no package.json); it was referenced only through the @shared TypeScript path alias. Relocate it under src/ via git mv (143 files, detected as pure renames).
Repoint the @shared alias and include globs to src/shared across electron.vite.config.ts, tsconfig.{json,node,web}.json and vitest.config.ts; update scripts/check-custom-exts.ts, scripts/update-languages.ts, the eslint.config.mjs generated-file globs, the data-classify generator output targets, .github/CODEOWNERS path rules, and CLAUDE.md/docs/source-comment references.
The @shared alias name is unchanged, so all 1403 @shared/* import sites resolve without modification. Verified with typecheck:node, typecheck:web and the full test suite (700 files, 9739 tests passing).
Document the app_state table as internal continuity markers: admission checklist, owner-held DB handle access (no dedicated service today), single-owner keys, scope-prefixed naming, disposability, and a key registry. Reference it from the data system-selection README and the seeding guide.
libsql client-ts upstream issue #288 makes PRAGMA busy_timeout ineffective
for async transactions, so concurrent db.transaction() calls reliably surface
SQLITE_BUSY. Introduce DbService.withWriteTx as a serialized write helper:
- Process-wide FIFO mutex (async-mutex) serializes write transactions.
- libsql client's default BEGIN IMMEDIATE protects against read-then-write
tx upgrade failures (no override needed at the drizzle layer).
- Single 50ms BUSY retry guards against transient external locks.
Reads do NOT need this — WAL gives readers snapshot isolation that is never
blocked by writers.
Includes unit tests (FIFO ordering, finally release on throw, single BUSY
retry, persistent BUSY rethrow, non-BUSY passthrough) plus a real-libsql
integration test. Updates the DbService test mock with a passthrough
withWriteTx so dependent services do not throw "is not a function" in
tests. Documents the API in database-patterns.md and points
CLAUDE.md / data-api-overview.md at the new pattern.
The rule that schema files in packages/shared/data/api/schemas/ are
organized by the entity's domain (not URL prefix) was followed in code
but never written down, so readers could reasonably misinfer "the URL
parent decides the file" from routes like /topics/:topicId/messages
living in messages.ts.
Add a Schema File Organization section to api-types.md, with a small
table comparing routes whose URL parent and returned entity disagree,
and a cross-reference note from api-design-guidelines.md so path-design
readers land on the right page.
- data-api-in-main: add Registry Sub-Resource Endpoints section -
GET-only for stateless reads, AIP-136 colon notation for derived
views, registry packages are main-only (bundle waste + merge
already in main)
- best-practice-layered-preset-pattern: preset-only static fields
must merge in rowToEntity rather than via parallel endpoint;
document acceptable exceptions for catalog and specialised
surfaces
- data-ordering-guide section 2: drop user_provider.isEnabled from
the Live partition list - the table is whole-table ordered
(already correct in section 7)
- database-patterns: flag boolean columns without .notNull() as a
common R3 offender, with concrete wrong/right example
All service methods accepting a Drizzle transaction now follow:
- tx is the first parameter
- method name ends with Tx
Renamed:
- pinService.purgeForEntity / purgeForEntities -> *Tx
- tagService.purgeForEntity / purgeForEntities -> *Tx
- TagService#assertTagsExist (private) -> assertTagsExistTx
- PromptService#assertPromptsExist (private) -> assertPromptsExistTx
- AssistantService#syncRelations (private) -> syncRelationsTx
- AgentService#syncSettingsToSessions (private) -> syncSettingsToSessionsTx
The convention is documented in docs/references/data/data-api-in-main.md
under the new "Transaction Method Naming" section. Behavior is unchanged.
### What this PR does
Migrates the MiniApp feature from v1 (Redux + sidecar
`custom-minapps.json`) to the v2 data architecture (DataApi + Preference
+ Cache), and integrates it into the v2 AppShell tab system.
**Before this PR**
- App lists lived in three Redux arrays (`enabled` / `disabled` /
`pinned`); custom-app logos were stripped before persistence and
recovered at runtime from `{userData}/Data/Files/custom-minapps.json`.
- Settings (`region`, `max_keep_alive`, `open_link_external`,
`show_opened_in_sidebar`) lived in legacy redux/electron-store.
- Runtime keep-alive used a module-level `lru-cache` singleton, mirrored
into v2 cache via `onInsert` / `disposeAfter` (two sources of truth —
already a known race).
- Routes were `/app/minapp/*`; sidebar icon literal was `'minapp'`.
- Sidebar mode used the legacy popup container; top-navbar mode was
non-functional.
**After this PR**
- A single `mini_app` SQLite table owns every row (preset + custom).
Preset rows are seeded by `MiniAppSeeder` from `PRESETS_MINI_APPS` on
every boot; custom rows come in via `POST /mini-apps`. The seeder uses
`setWhere isNotNull(presetMiniappId)` so refreshing preset display
fields can never overwrite a custom row whose `appId` happens to collide
with a preset.
- `MiniAppMigrator` imports v1 Redux state and reads
`custom-minapps.json` (path resolved through
`MigrationPaths.customMiniAppsFile`) to recover stripped logos.
- Settings live under typed Preference keys
(`feature.mini_app.{region,max_keep_alive,open_link_external}`); sidebar
icon literal renamed `'minapp'` → `'mini_app'` with a complex preference
transform that rewrites existing user arrays in-place.
- API: `GET/POST/PATCH/DELETE /mini-apps` + `POST
/mini-apps/order:batch`, Zod-validated, fractional-indexing ordering
scoped by `status` (cross-status batches are rejected with
`VALIDATION_ERROR` per the data-ordering-guide contract). Status
transitions reassign `orderKey` to the tail of the target partition
inside a transaction.
- Renderer hook `useMiniApps` exposes **command-style** writes only:
`updateAppStatus(id, status)` and `setAppStatusBulk([{id, status}])`.
The legacy declarative `updateMiniApps(list)` /
`updateDisabledMiniApps(list)` / `updatePinnedMiniApps(list)` are gone —
they took region-filtered subsets and silently disabled rows the caller
never saw.
- Keep-alive list is stored solely in
`useCache('mini_app.opened_keep_alive')`. Cap eviction respects AppShell
pin status: `useMiniAppPopup` reads pinned mini-app routes from
`useTabs` and skips them in eviction. `MiniAppTabsPool` renders webviews
in a stable `appId`-sorted order so LRU reorders never move `<webview>`
DOM nodes (Electron `<webview>` loses its guest WebContents on
detach/reattach).
- **Unified launch path**: clicking any miniapp (from the launcher grid
or a top tab bar entry) calls `openTab('/app/mini-app/<id>', { title,
icon: app.logo })`. A globally-mounted `<MiniAppTabsPool>` in `AppShell`
keeps a `<webview>` alive per opened app, regardless of sidebar vs
top-navbar layout.
- Settings UI rewritten as a `PageSidePanel` drawer composed of
`MiniAppListPair` (visible / hidden columns with drag-drop) and
`MiniAppDisplaySettings` (region / cache slider). New custom-app form is
a separate `NewMiniAppPanel` drawer.
- Sidebar's running-mini-apps strip removed — opened apps live
exclusively in the top tab bar (per #3198804265). Companion preference
`feature.mini_app.show_opened_in_sidebar` deleted from the schema.
### Why we need it and why it was done in this way
Part of the broader v2 data-layer migration (Redux/Dexie/ElectronStore →
DataApi + Preference + Cache).
**Architecture**
- DataApi for entity rows (preserves user content); Preference for
atomic settings; Cache (Memory tier) for runtime ephemera.
- Layered preset pattern (`best-practice-layered-preset-pattern.md`):
preset and custom rows share the same table, discriminated by
`presetMiniappId`. Seeder refreshes preset display fields on re-run;
custom rows are immutable to the seeder.
- Region filtering is a **view-only** concern (read path); the write
path is command-style and never references region. This eliminated a
class of bugs where editing the visible (filtered) list caused
region-hidden rows to drift.
- AppShell tab pinning is the canonical "keep this loaded" signal. The
keep-alive cap respects it; pinned mini-app tabs never get evicted
regardless of cap. Render-order independence in `MiniAppTabsPool`
ensures LRU touches don't move `<webview>` nodes around.
- Per-app icon resolution: `app.logo` is a `CompoundIcon` id (e.g.
`"Moonshot"`) for presets and a URL for custom apps. UI consumers (tab
bar, sidebar entry, settings list) call `getMiniAppsLogo` to resolve the
id to a `CompoundIcon` before rendering, with `<img>` fallback for URL
strings.
- Per-entity tab icons are cleared on internal navigation, sidebar
reuse, and the top-bar settings button — three call sites that all flip
the active tab's URL now consistently reset `icon: undefined` so a
mini-app logo never sticks onto an unrelated route.
**Tradeoffs**
- `useMiniApps` still exposes `miniapps` (region-filtered
enabled+pinned) and `disabled` (region-filtered). These are display-only
views. Renamed/typed wrappers were considered but deferred — the
refactor to command-style writes already eliminated the bug class that
motivated the rename.
- The `applyReorderedList` integration test for
`reorderMiniAppsByStatus` was dropped — `MockUseDataApiUtils` doesn't
fill the SWR cache that `useReorder.readCurrent` reads. Splice logic is
straightforward and the server-side `applyScopedMoves` test covers the
contract.
- Sidebar primitives in `@cherrystudio/ui`-adjacent layout still accept
`miniAppTabs` / `onMiniAppTabClick` props (defensive defaults — render
nothing without a producer). Removing these from the primitive's API is
a separate refactor not in scope.
### Breaking changes
User-visible changes are auto-migrated by the v2 migration framework —
no manual user action required:
- Sidebar icon literal `'minapp'` → `'mini_app'` (rewritten by the
`sidebar_icons_rename` complex preference transform)
- Preference key rename `feature.minapp.*` → `feature.mini_app.*`
(auto-migrated via `classification.json`)
- Custom-app logos stripped from v1 Redux are recovered from
`custom-minapps.json` during migration
One product-shape change is documented under
`v2-refactor-temp/docs/breaking-changes/`:
- `2026-05-07-miniapp-sidebar-running-list-removed.md` — the sidebar no
longer surfaces opened mini-apps under the mini-app entry. Open apps are
accessed exclusively via the top tab bar; pin a tab to keep its state
across switches.
The legacy v1 preference `showOpenedMinappsInSidebar` is reclassified as
`status: deleted` in the migration pipeline; v1 values are dropped
during v1→v2 migration with no v2 destination.
### Special notes for your reviewer
**Verified end-to-end on a real dev profile**: v1 Redux state +
`custom-minapps.json` → v2 SQLite, including pinned-app cross-group
dedup (a v1 pinned app appears in both `pinned` and `enabled` Redux
arrays; the migrator counts duplicates as skipped so the engine's
`targetCount >= sourceCount - skippedCount` invariant holds — without
this, any user with pinned miniapps was blocked from migrating).
**Drizzle migrations** are throwaway in dev per `CLAUDE.md`.
`migrations/sqlite-drizzle/0020_even_hulk.sql` is the single regenerated
migration; it will be wiped to a clean initial migration before release.
**Review history**: 28 line-comments across multiple formal review
rounds. All resolved. The most consequential fixes:
- `applyScopedMoves` in `MiniAppService.reorder` — rejects cross-status
batches with `VALIDATION_ERROR` instead of silently splitting them.
- `update()` reassigns `orderKey` to a fresh tail in the target
partition on status change.
- Empty-string substitution in migrator mappings is now caught by the
post-transform validity check; bad rows are skipped + warned, never
inserted.
- Migrator validation switched from `limit(5)` sample to full `count(*)
WHERE empty-fields` — bad rows can no longer pass validation by virtue
of being beyond the sample window.
- Keep-alive cap exempts pinned tabs (#3198809321 + the kangfenmao
keepalive review); render order in `MiniAppTabsPool` is `appId`-stable
so LRU touches don't move `<webview>` nodes (this was the root cause of
"switching tabs reloads the webview").
**Out of scope**:
- The remaining `@renderer/store/tabs` import in
`PaintingsRoutePage.tsx` is pre-existing v1 residual (not introduced or
touched by this PR).
### Checklist
- [x] PR: description rewritten to reflect the final architecture +
integration with the AppShell tab system
- [x] Code: command-style writes (`updateAppStatus` /
`setAppStatusBulk`); see `useMiniApps`, `MiniAppService`,
`MiniAppMigrator`, `MiniAppTabsPool`, `useMiniAppPopup` for the main
entry points
- [x] Refactor: ~1500 lines of dead/legacy code removed
(`Tab/TabContainer`, `TabsService`, `MiniAppPopupContainer`,
`TopViewMiniAppContainer`, legacy LRU singleton, `PinnedMiniApps`, dead
`userOverrides` / `MiniAppRegistryService`, unused `Signal.ts`)
- [x] Upgrade: v1 → v2 migration verified end-to-end on a real dev
instance
- [x] Documentation: architecture covered by `docs/references/data/`;
one user-visible behavior change documented in
`v2-refactor-temp/docs/breaking-changes/`
- [x] Self-review: multi-agent review via `/gh-pr-review` (twice); all
28 review comments resolved
### Release note
```release-note
NONE - Internal v2 data refactor. User-facing renames (route, sidebar icon, preference keys) are auto-migrated. The sidebar no longer shows a running-mini-apps strip; opened apps live in the top tab bar.
```
---------
Signed-off-by: suyao <sy20010504@gmail.com>
Signed-off-by: chengcheng84 <hello_world0000@outlook.com>
Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
The "DB defaults are near-permanent" guidance previously claimed
drizzle-kit cannot auto-generate the SQLite table rebuild for DEFAULT
changes. That's incorrect: drizzle-kit emits the full PRAGMA / CREATE
__new_xxx / INSERT SELECT / DROP / RENAME / re-create-indexes sequence
automatically.
Rewrite the supporting argument from "tooling can't do it" to:
- every change forces a full-table rebuild at runtime (schema lock,
~2x temporary disk, slow on multi-GB tables);
- DEFAULT changes never touch existing rows;
- legacy NULL backfill must be hand-written into the rebuild's
INSERT SELECT line via COALESCE - drizzle-kit will not synthesize
that.
Conclusion (near-permanent) and Safe bias remain unchanged - only the
underlying mechanics are corrected.
Drop the drizzle-orm#2489 reference and the "drizzle-kit generate
--custom" workaround it implied.
Codify what was implicit: putting a value into a DB column DEFAULT for the first time costs nothing, but changing it later in SQLite is expensive and asymmetric (no ALTER COLUMN SET DEFAULT; drizzle-kit emits only an explanatory comment without naming the affected column — drizzle-orm#2489). So the first write is effectively the final one, and the placement bias should flip from "DB by default" to "service unless certain".
Verified via drizzle-orm maintainer (Andrii Sherman) Medium article, drizzle-orm GitHub issues #2489 / #5360 / #1313, and Drizzle docs via context7. Empirical: this repo's 7 existing migrations are all ALTER TABLE ADD COLUMN — zero ALTER COLUMN — confirming the team has so far avoided the manual rebuild path organically.
Spec changes in best-practice-default-values-and-nullability.md:
- New section "DB defaults are near-permanent" between DM2 and Quick chooser
- DM2 → DB DEFAULT row note tightened, links to new section
- Quick chooser flipped: "unsure" now → Service ?? (was "try DB first")
- Standard Layered Design: emoji moved from DB DEFAULT to service ?? (product-chosen value)
- Anti-patterns: emoji-mask row's Correct column updated; new row for "speculative DB DEFAULT thinking I can tune later"
- Case Study A's Fix description aligned with new bias
- Related References: drizzle-orm#2489 added
Companion update in database-patterns.md: same precision for the DB .default('X') cell in "Where the default value lives".
Establish team standards for placing default values across the data stack (DB DEFAULT / Drizzle $defaultFn / Zod .default() / service ??) and judging column nullability.
Originating context: PR #14689 fixed a PATCH leakage bug rooted in defaults living in three places at once (DB, Zod Create schema, row mapper) for the assistant entity. The follow-up discussion recovered general principles that other entities (agent, message) also violate; this doc captures them as a reference for future schema/service work.
- New: best-practice-default-values-and-nullability.md — Five rules (R1-R5), decision matrices for nullability and default placement, standard layered design example, anti-patterns table, case studies (assistant, modelId, agent.accessiblePaths)
- api-design-guidelines.md: refine Rule C Update derivation guidance; add Rule E discouraging Zod .default() on entity / Create / Update schemas
- data-api-in-main.md: upgrade row-mapper ?? fallbacks from "needs hand-write" tolerance to anti-pattern; add Write-path defaults section codifying R4
- database-patterns.md: add Column Nullability and Defaults section; add R3 no-fabricated-fallbacks bullet to Row → Entity Mapping
- README.md: index entry under Reference Guides
No code changes. Implementation follow-up will land in separate PRs that apply these rules entity by entity.
Mirror the existing single-row purgeForEntity on PinService and TagService
with a purgeForEntities(tx, entityType, ids[]) variant. Empty input is a
no-op; non-empty input collapses N round trips into one and emits a single
aggregated log line with a count, removing the "loop and log per id" smell
when consumers later cascade deletes for many entities of the same type.
Document a Cross-Service Table Access rule in data-api-in-main.md:
- Writes to a foreign table must go through the owner (pass tx into its
method) — owners are the single source of truth for invariants and the
emitter of mutation logs; foreign writes split that knowledge.
- Reads that inline-JOIN a foreign table are allowed when JOIN is the
simpler path (e.g. AssistantService.list pulling per-row tags).
The new bulk methods are intentionally landed without a consumer so that
the "add a bulk method on the owner instead of writing raw SQL from the
caller" path described by the rule is available the moment a cascading
delete appears, rather than nudging the first author to bypass the owner.
Fixes#14593 (mutate type collapses to CursorPaginationResponse, erasing
subtype fields like BranchMessagesResponse.activeNodeId) and #14594
(default flatMap bakes in a "page-load order == display order" assumption
that breaks on branch-walk + column-reverse layouts). Also closes a dual
silent-failure where useInfiniteQuery silently accepted offset paths
(stuck at page 1) and usePaginatedQuery silently accepted cursor paths
(total stays 0).
useInfiniteQuery now exposes raw pages: TResponse[] (no items field),
preserving subtype fields and typing mutate as SWRInfiniteKeyedMutator
correctly. The new useInfiniteFlatItems hook derives the flat list with
explicit reversePages / reverseItems switches, so flattening is no longer
hidden behind an implicit page-load ordering. Path generics on both hooks
are gated via CursorPaginatedPath / OffsetPaginatedPath, built on
InferPaginationMode so the optional nextCursor field cannot collapse
offset paths into the cursor guard structurally.
DEFAULT_SWR_OPTIONS realigned for IPC (not HTTP) semantics: DataApiService
is the single retry decision point (shouldRetryOnError: false), reconnect
revalidation disabled, keepPreviousData enabled to suppress search/
pagination flicker. loadNext drops its isValidating guard (SWR's
dedupingInterval is the right dedup site); usePaginatedQuery
reset-on-query-change uses unstable_serialize to be key-order independent.
useInfiniteQuery had zero consumers when rewritten — the breaking removal
of the items field carries no migration cost. Comprehensive test coverage
for type contracts, flat-items behavior, infinite integration, and
paginated reset; renderer data docs synced.
All 14 per-module handler files used the same 7-line mapped type.
Extract it into a single HandlersFor<Schemas> helper in apiTypes and
migrate each file to a one-line annotation (net -119 lines).
The helper is defined as Pick<ApiImplementation, Extract<keyof Schemas,
keyof ApiImplementation>> rather than re-applying ApiHandler<Path, Method>
over generic parameters -- the latter triggers TS2590 (union too complex)
because ApiHandler nests several conditional types that must resolve
symbolically when Path is a free type variable. Indexing the
already-evaluated ApiImplementation sidesteps that.
Also elevate the annotation rule to a dedicated "Handler Type Annotation"
subsection in the data-api-in-main doc and add a type-level regression
matrix under handlers/__tests__.
Align cache-overview.md and cache-usage.md with the template-key and
subscribe* APIs (sharedCasual was dropped in 3fbc52e05). Extract the
"adding keys" content into a new cache-schema-guide.md aligned with
preference and boot-config schema guides. Lift non-obvious invariants
(isEqual short-circuit, TTL-with-hooks warning, Persist has no delete,
Main-wins convergence, template placeholder rules) into a first-class
Design Invariants section in the overview.
Fix two code-contradicted claims:
- useCache does not accept a TTL options argument (hook signature is
(key, initValue?)).
- Persist is renderer-authoritative; Main only relays IPC and does
not store (CacheService.ts:477-479 is "Reserved, not implemented").
Update peripheral references in the same pass so cross-references stay
coherent: v2-renderer skill, CLAUDE.md, architecture-overview.md,
api-design-guidelines.md (Cache vs DataApi matcher contrast), and the
package READMEs.
Net change: +249 / -579.
Enables main-process services to react to cache changes without writer-side
wiring — unblocks the web-search/OCR provider rotation use case where new
providers would otherwise require manual hook-ups at every write site.
Also unifies equality across main and renderer on lodash.isEqual (fixing
redundant cross-window broadcasts when Record/Array values are rebuilt on
every write) and extracts template utilities to the shared package so both
processes use one implementation.
Align SharedCache type system with Memory (UseCache) template support and
remove the sharedCasual escape hatch that existed only because SharedCache
could not type-check dynamic keys:
- Introduce InferSharedCacheValue and expand SharedCacheKey through
ProcessKey so template schema entries like
'web_search.provider.last_used_key.${providerId}' match concrete keys
with precise value types on both Main and Renderer.
- Extend useSharedCache hook with findMatchingSharedCacheSchemaKey /
getSharedCacheDefaultValue, mirroring useCache's template-aware default
resolution.
- Remove getSharedCasual / setSharedCasual / hasSharedCasual /
deleteSharedCasual / hasSharedTTLCasual from the Renderer CacheService
and all test mocks; Main CacheService never had them.
- Migrate BaseWebSearchProvider and OcrBaseApiClient rotation from
sharedCasual to type-safe getShared/setShared. Rename keys to conform
to schema naming rules (ESLint data-schema-key/valid-key):
web-search-provider:${id}:last_used_key
-> web_search.provider.last_used_key.${providerId}
ocr_provider:${id}:last_used_key
-> ocr.provider.last_used_key.${providerId}
Old values under legacy key names become orphans after rollout; this
is acceptable because rotation state is transient and consumers
reinitialize from keys[0] on a miss.
- Add type-level assertions for SharedCacheKey and InferSharedCacheValue
in useCache.types.test.ts to lock the contract in CI.
- Update cache-overview.md, cache-usage.md, and tests/__mocks__/README.md
to describe SharedCache's template support and reflect that casual
methods now exist only on the Memory tier.
- Add "Zod Schema & DTO Conventions" section to api-design-guidelines.md
covering the four rules applied across the prior refactor commits:
A. type (not interface) for XxxSchemas route tables
B. XxxSchema for Zod constants, XxxDto for TypeScript type names
C. EntitySchema.pick({...}) whitelist derivation with field atoms
and z.strictObject (guarded against overposting)
D. handwritten Zod everywhere; no drizzle-zod; no pure TS interface
DTOs (responses stay as interface — opposite direction of the
IPC trust boundary)
- Include a decision rule for when to extract an XXX_MUTABLE_FIELDS
constant: both Create/Update share the pick set AND fields >= 5
- Sync example code in api-types.md to the new conventions
- Flip the TopicSchemas example in data-api-in-main.md from interface
to type
Previously the hook hard-coded a `{ items: Array<...> }` cache contract,
so the two resources actually shipping OrderEndpoints — /pins and
/groups — would have thrown `length mismatch` on first drag because
their GET responses are flat arrays (Pin[], Group[]).
Auto-detect flat arrays and `.items`-bearing objects; expose paired
selectItems/updateItems accessors for nested shapes (grouped views,
GraphQL-style connections, envelopes with a different field name).
Half-configured accessors throw at hook construction.
Degradation splits into two tiers: "cache not loaded" (all paths no-op
and warn per call) vs "shape unrecognized" — move/applyBatch let the
PATCH through since id + anchor are self-contained, while
applyReorderedList refuses since blind whole-list reorder without a
client baseline is unsafe. Unrecognized-shape warnings are
de-duplicated per hook instance.
Paginated shapes (OffsetPaginationResponse / CursorPaginationResponse)
now preserve non-items metadata (total, page, nextCursor) on optimistic
overlays.
Update the guide to match what's now landed in code:
- §2: drop the `reserved groupId = '__pinned__'` bucket teaching — pin
is a separate table, not an overloaded scope value. Point readers
to the schema/service JSDoc for resource-design details.
- §3: add applyScopedMoves to the helper table.
- §3.1 (new): document the scoped-reorder contract (service-side
scope inference, multi-scope batch rejection, missing-id semantics)
in one short section.
- §9 "Group Ordering" upgraded from future-extension placeholder to
spec, trimmed to ordering-relevant points only.
- §10 "Pin Ordering" added, also ordering-only. Polymorphic shape,
purgeForEntity contract, idempotent pin semantics, and hard-delete
rationale are deliberately NOT duplicated here — they live in
pin.ts / PinService.ts JSDoc where the code is.
- Partition-dimensions list and migration checklist updated so
`group.entityType` / `pin.entityType` show as live rather than
hypothetical.
Add `.notNull()` to `createdAt` / `updatedAt` in the shared
`createUpdateTimestamps` helper so Drizzle `$inferSelect` produces
`number` instead of the misleading `number | null`. `deletedAt` in
`createUpdateDeleteTimestamps` stays nullable (soft-delete semantics).
Generated migration 0013 rebuilds 26 affected tables via the standard
SQLite table-recreation pattern; FK / CHECK / INDEX constraints are
preserved across rebuild. No backfill is added (project is in the
development phase; null pre-existing rows are accepted as a "wipe DB"
signal rather than engineered around).
Fix upstream in `KnowledgeMappings.toTimestamp` so it returns a
`Date.now()` fallback instead of `undefined` — otherwise future Dexie
-> v2 migrator runs would try to insert undefined into NOT NULL
columns. Three test assertions updated from `undefined` to
`expect.any(Number)`.
Sweep 18 downstream call sites across 9 functions that were carrying a
dead `?? new Date().toISOString()` fallback:
- AssistantService, KnowledgeBaseService, KnowledgeItemService,
MessageService, TopicService, TranslateHistoryService,
TranslateLanguageService (the original Pattern A set)
- McpServerService and MiniAppService.rowToMiniApp (reclassified from
Pattern B: the domain types stay `optional` to accommodate builtin
literals in the renderer, but `string` assigns legally into
`string | undefined`, so the switch is safe)
Keep `MiniAppService.builtinToMiniApp` on `timestampToISOOrUndefined`
— its `dbRow?: MiniAppSelect` semantics ("the preference row may not
exist at all") is genuinely optional, not a disguised "nullable column".
Also remove a Pattern C that neither the plan nor the grep audit
caught: `TagService.ensureTagTimestamp` was a self-rolled defense
layer that threw INTERNAL_SERVER_ERROR on null timestamps. The DB
now refuses to produce such rows, so the defense — and the test
named "should surface timestamp anomalies instead of masking them" —
are dead code. Removed both.
Update three docs to reflect the new defaults:
- `services/utils/README.md` — drop the "DB still nullable" table row
and the predictive paragraph; reframe Pattern B around
"whole row may not exist"
- `services/utils/rowMappers.ts` JSDoc — same reframing
- `docs/references/data/data-api-in-main.md` — delete the fallback
code samples and simplify Convention §3
Introduce shared `services/utils/rowMappers.ts` exporting three helpers:
`nullsToUndefined` (renamed from the previous `stripNulls`, with a
corrected type signature that preserves `notNull()` columns unchanged),
`timestampToISO` for guaranteed-present timestamps, and
`timestampToISOOrUndefined` for nullable ones.
Migrate 9 services off hand-rolled null/date handling:
- Eliminate the `stripNulls` duplicate in MiniAppService
- Replace 18 repetitive createdAt/updatedAt ternaries with helper calls
- Fix McpServerService misuse where an optional domain field was being
forced to a synthesized "now" value; restore honest undefined semantics
- Simplify KnowledgeBaseService.rowToKnowledgeBase via the spread idiom,
bypassing `clean` for the `T | null` embeddingModelId field
Document the paradigm in docs/references/data/data-api-in-main.md with
standard and advanced skeletons, plus an explicit "when NOT to spread"
list covering services with field renaming, custom fallbacks, computed
fields, or sensitive-data sanitization. Per-helper design decisions
(shallow vs. recursive, rejected alternatives) live in
services/utils/README.md.
Extend the renderer data hooks to cover three mutation shapes that
previously had to drop to imperative dataApiService calls:
- Template paths (e.g. `/providers/:providerId`) with a runtime `params`
option on useQuery / useMutation / useInfiniteQuery / usePaginatedQuery,
so a single hook instance can operate on ids chosen at call time.
ParamsForPath derives types directly from the schema's existing
`params: {...}` declarations (no template-string parsing).
- Function-form `refresh: ({ args, result }) => ConcreteApiPaths[]` for
invalidation keys that depend on trigger input or server response. Args
are closure-captured at trigger entry to avoid races between concurrent
calls.
- Explicit `/*` suffix for path-segment prefix matching on refresh and
invalidate patterns, preserving the trailing slash so `/providers/*`
doesn't match siblings like `/providers-archived`.
A single `resolveTemplate` function is the canonical path-replacement
point, so `useQuery('/providers/:id', { params: { id: 'abc' } })` and
`useQuery('/providers/abc')` produce byte-for-byte identical cache keys.
Dev-mode assertions flag invalid `/*` patterns (bare wildcard or missing
trailing slash) and warn on concurrent triggers against the same template
hook instance, which would share SWR mutation state.
Fully backward compatible: existing `refresh: ['/topics']` and
concrete-path hook calls compile and behave identically.