refactor(file-entry): rename trashedAt to deletedAt (#15246)

### What this PR does

Before this PR:

- `file_entry` table used `trashed_at` for the soft-delete timestamp,
diverging from every other soft-deletable table in the schema (`agent`,
`assistant`, `message`, `topic`), which all use `deleted_at`.

After this PR:

- `file_entry.deleted_at` (and BO field `deletedAt`) — naming is
consistent across the entire schema.
- Renamed identifiers:
  - Schema field: `trashedAt` → `deletedAt`
  - SQL column: `trashed_at` → `deleted_at`
  - Index: `fe_trashed_at_idx` → `fe_deleted_at_idx`
  - CHECK constraint: `fe_external_no_trash` → `fe_external_no_delete`
- Updated all source files, tests, and architecture docs (including
`v2-refactor-temp/docs/file-manager/`).
- **Intentionally NOT renamed** (out of scope — these are API surface /
concept names, not the column name): `moveToTrash`, `restoreFromTrash`,
`inTrash` (query flag), `isTrashed`, `batchTrash`, `internalTrash`, and
"Trash" as a concept in comments/docs.

Fixes #

### Why we need it and why it was done in this way

The following tradeoffs were made:

- **Scope discipline**: kept the rename strictly at the
column-identifier layer (4 identifiers). Did not change API names or
concept words — switching the "Trash" concept to "Delete" is a larger
semantic change that deserves its own PR.
- **Migration 0026 contains a manual SQL patch.**
drizzle-orm/drizzle-kit issue
[#3653](https://github.com/drizzle-team/drizzle-orm/issues/3653) causes
the SQLite rebuild-table path to drop the leading `ALTER TABLE … RENAME
COLUMN` statement. The generated `INSERT … SELECT "deleted_at" FROM
file_entry` would fail because the source table still has `trashed_at`.
The migration manually prepends an explicit `ALTER TABLE file_entry
RENAME COLUMN trashed_at TO deleted_at;` before the rebuild. Upstream
fix landed in `drizzle-kit@1.0.0-beta`/`rc` but is not backported to the
`0.31.x` stable line we depend on.
- **Why keeping the manual patch is acceptable**: per `CLAUDE.md` § v2
Refactoring, `migrations/sqlite-drizzle/` is throwaway during v2 — it
will be wiped and regenerated as a single clean initial migration from
the final schemas before release. Mid-development DB drift is explicitly
acceptable, and the manual SQL only needs to survive until that
regeneration.

The following alternatives were considered:

- Selecting `create column` in `drizzle-kit generate` instead of `rename
column`: also produces invalid SQL (same root cause — the rebuild path
puts the new column name in the `SELECT` list regardless of the rename
mapping). Rejected.
- Skipping the `0026` migration entirely and relying on `db:push` / DB
reset during dev: pollutes `_journal.json` divergence and makes the next
schema change confusing. Rejected.
- Upgrading to `drizzle-kit@1.0.0-beta`/`rc` to get the fix: v1 is a
major rewrite with significant breaking changes (alternation engine
rewrite, ORM type system rewrite, migration folder layout change). Out
of scope for this PR. Rejected.

Links to places where the discussion took place: N/A

### Breaking changes

None. Dev-only DB column rename during v2 refactor. No user-visible
behavior change. No public API surface change. v1 data never reaches
this branch except through migrators in `src/main/data/migration/v2/`.

### Special notes for your reviewer

- The single manual edit to drizzle-generated SQL is in
`migrations/sqlite-drizzle/0026_sturdy_aqueduct.sql` — look for the
`MANUAL PATCH` comment block at the top. Without it the migration will
fail to apply.
- "Trash" concept words still appear throughout the file-manager
codebase by design (function names, comments, docs section headings). If
we later want to migrate the whole concept to "Delete", that should be a
follow-up PR.

### Checklist

This checklist is not enforcing, but it's a reminder of items that could
be relevant to every PR.
Approvers are expected to review this list.

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others

### Release note

```release-note
NONE
```

---------

Signed-off-by: icarus <eurfelux@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Phantom
2026-05-21 23:04:36 +08:00
committed by GitHub
parent 35913abbeb
commit 6ec914cf0f
31 changed files with 3896 additions and 188 deletions

View File

@@ -161,7 +161,7 @@ Reference layer FileEntryHandle FilePathHandle
▼ FileManager.getEntry ▼ fs.stat + projection
Data-shape layer FileEntry FileInfo
(after resolution) { id, origin, name, ext, { path, name, ext, size,
size, trashedAt, ... } mime, type, modifiedAt, ... }
size, deletedAt, ... } mime, type, modifiedAt, ... }
```
Picking a handle variant is a **call-site choice of reference form**, not a statement about the file itself. Crucially, **the two axes are orthogonal**:
@@ -192,7 +192,7 @@ Once a handle is dispatched, the handler works with either a `FileEntry` (the DB
**Field overlap is inherent, not redundant**: `name`, `ext`, `type` (and `mime` / `size` on `FileInfo`) describe a file regardless of whether an entry row exists for it. What distinguishes the two types is the *surrounding* fields and the *liveness* of the shared ones:
- **`FileEntry` has identity fields** `FileInfo` lacks: `id`, `origin`, `externalPath`, `trashedAt`.
- **`FileEntry` has identity fields** `FileInfo` lacks: `id`, `origin`, `externalPath`, `deletedAt`.
- **`FileInfo` has live fields** `FileEntry` lacks: `path` (derived, never stored on `FileEntry`), `modifiedAt`, and a live `size`.
- **`FileEntry.size` is origin-gated**. For `origin='internal'` it is an authoritative byte count (kept in sync by atomic writes). For `origin='external'` it is **always `null`** — external files may change outside Cherry at any time, so no DB snapshot is stored. Consumers that need a live value for an external entry call File IPC `getMetadata(id)`, which runs `fs.stat` on demand. This eliminates the "is this snapshot current?" question at the type level rather than at call sites.
- **`FileEntry.name` / `FileEntry.ext` never drift**. For internal they are user-editable SoT; for external they are pure projections of `externalPath` (basename / extname) and therefore stable as long as the entry itself exists.
@@ -272,7 +272,7 @@ All operations that can act on any file (FileEntry or arbitrary path) **accept a
|---|---|
| `createInternalEntry` / `batchCreateInternalEntries` | Create a new Cherry-owned FileEntry (writes to `{userData}/Data/Files/{id}.{ext}`; each call produces an independent new entry, no conflict possible) |
| `ensureExternalEntry` / `batchEnsureExternalEntries` | Pure upsert by `externalPath`—the entry point first `canonicalizeExternalPath(raw)` normalizes it (see `pathResolver.ts`); reuses the existing entry with the same path or inserts a new one. Idempotent by design—callers may safely repeat calls. No "restore" branch: external entries cannot be trashed. External rows carry no stored `size` (always `null`); live values come from `getMetadata`. |
| `trash` / `restore` | Soft delete based on trashedAt (DB only). **Internal-origin only** — external-origin entries cannot be trashed (`fe_external_no_trash` CHECK); passing an external id throws. |
| `trash` / `restore` | Soft delete based on deletedAt (DB only). **Internal-origin only** — external-origin entries cannot be trashed (`fe_external_no_delete` CHECK); passing an external id throws. |
| `batchTrash` / `batchRestore` | Batch versions of `trash` / `restore` — same internal-origin-only rule. |
| `batchPermanentDelete` | Batch version of `permanentDelete`. |
| `withTempCopy` | Copy isolation for calling third-party libraries |
@@ -299,7 +299,7 @@ All operations that can act on any file (FileEntry or arbitrary path) **accept a
| User action | Physical external file |
|---|---|
| Trash from Cherry | **Not applicable** — external-origin entries cannot be trashed (`fe_external_no_trash` CHECK) |
| Trash from Cherry | **Not applicable** — external-origin entries cannot be trashed (`fe_external_no_delete` CHECK) |
| Restore from Cherry | **Not applicable** — external-origin entries are never trashed |
| permanentDelete from Cherry (entry-level) | **Untouched** — only the DB row is deleted; the physical file remains on disk |
| write / writeIfUnchanged from Cherry | **Overwritten** (atomic write) |
@@ -469,7 +469,7 @@ DataApi endpoints (read-only, SQL-only, fixed-shape):
>
> Rule of thumb: if a handler must call anything outside the Drizzle / `@db/*` surface to answer the request, it belongs in IPC. If two callers want the same data in different shapes, the answer is **two endpoints**, not one endpoint with a flag.
**List queries for external entries**: DataApi returns the DB row directly — identity (`id`, `origin`, `externalPath`), stable projections (`name`, `ext`), timestamps, `trashedAt`. External rows carry `size: null` by design (no snapshot stored). Consumers needing **live `size` / `mtime`** call File IPC `getMetadata(id)`; those needing only **whether the file currently exists** (dangling) call File IPC `getDanglingState` / `batchGetDanglingStates`.
**List queries for external entries**: DataApi returns the DB row directly — identity (`id`, `origin`, `externalPath`), stable projections (`name`, `ext`), timestamps, `deletedAt`. External rows carry `size: null` by design (no snapshot stored). Consumers needing **live `size` / `mtime`** call File IPC `getMetadata(id)`; those needing only **whether the file currently exists** (dangling) call File IPC `getDanglingState` / `batchGetDanglingStates`.
### 4.1.1 DataApi Boundary: SQL-Only, Fixed Shape
@@ -1028,7 +1028,7 @@ src/main/utils/file/ -- pure FS primitives, sole FS owner, open
- **External entry is a best-effort reference**: no guarantee the file remains stable, no guarantee content matches the reference-time content. Equivalent to "the user expressed intent to reference this path at some point" semantics in tools like codex
- **External entry path is globally unique**: at most one row per `externalPath` at any time, regardless of any state (SQLite global unique index on `externalPath`; internal rows have `externalPath = null` and are exempt, since SQLite treats multiple NULLs as distinct). `ensureExternalEntry` is therefore a pure upsert by path — reuse if an entry exists, otherwise insert; no "restore" branch is possible because external entries cannot be trashed.
- **External entries cannot be trashed**: enforced at the DB layer by `CHECK (origin != 'external' OR trashedAt IS NULL)` (`fe_external_no_trash`). External lifecycle is monotonic: create via `ensureExternalEntry` → update in place via `write` / `rename` → remove via `permanentDelete` (DB row only). There is no soft-delete / restore cycle for external entries. Calling `trash` / `restore` on an external id throws.
- **External entries cannot be trashed**: enforced at the DB layer by `CHECK (origin != 'external' OR deletedAt IS NULL)` (`fe_external_no_delete`). External lifecycle is monotonic: create via `ensureExternalEntry` → update in place via `write` / `rename` → remove via `permanentDelete` (DB row only). There is no soft-delete / restore cycle for external entries. Calling `trash` / `restore` on an external id throws.
- **External entries allow explicit user edits**: `write` / `writeIfUnchanged` / `createWriteStream` / `rename` take effect on external (delegated to ops' atomic write / fs.rename), triggered by explicit user action. Cherry does **not** perform automatic / watcher-driven external file modifications
- **`permanentDelete` on external is entry-level, not file-level**: removes only the DB row + CASCADE-cleans `file_ref`; the physical file is left untouched. Path-level deletion remains available via `remove(path)` (from `@main/utils/file/fs`, reached through a `FilePathHandle`), which is a separate explicit call not bound to any entry id.
- **Cherry does not track rename/move of external files**: an external rename turns the entry dangling; the user must re-@ to establish a new reference

View File

@@ -33,7 +33,7 @@ FileEntry
├── ext: extension (without leading dot), nullable
├── size: bytes
├── externalPath: absolute path, non-null only when origin='external'
├── trashedAt: ms epoch | null
├── deletedAt: ms epoch | null
├── createdAt / updatedAt
```
@@ -650,17 +650,17 @@ The `atomicWriteFile` / `atomicWriteIfUnchanged` / `createAtomicWriteStream` pri
## 6. Deletion and Recycle Bin
### 6.1 trashedAt Model
### 6.1 deletedAt Model
All soft deletes are implemented via the `trashedAt` timestamp, without physically moving files:
All soft deletes are implemented via the `deletedAt` timestamp, without physically moving files:
| Operation | Physical impact (internal) | Physical impact (external) |
|---|---|---|
| `trash(id)` | None | **N/A** (`fe_external_no_trash` CHECK rejects; external rows cannot be trashed) |
| `trash(id)` | None | **N/A** (`fe_external_no_delete` CHECK rejects; external rows cannot be trashed) |
| `restore(id)` | None | **N/A** (no trashed external rows to restore) |
| `permanentDelete(id)` | DB delete + best-effort `remove(physicalPath)` (`@main/utils/file/fs`) | **DB delete only — user's file is never modified** (matches `architecture.md §3.4`) |
**trash / restore are internal-only.** External entries cannot be trashed by definition (`fe_external_no_trash` CHECK enforces this); the trash semantics make sense only for files Cherry owns.
**trash / restore are internal-only.** External entries cannot be trashed by definition (`fe_external_no_delete` CHECK enforces this); the trash semantics make sense only for files Cherry owns.
**permanentDelete on internal**: DB row is removed first, then the physical file at `{userData}/Data/Files/{id}.{ext}` is best-effort unlinked. Unlink failures (ENOENT, insufficient permissions, etc.) are logged but do not block — the DB-row-gone outcome is what callers observe; any orphaned blob is later cleaned by the startup file sweep (§10).
@@ -669,7 +669,7 @@ All soft deletes are implemented via the `trashedAt` timestamp, without physical
### 6.2 Auto Expiry (deferred — lands in Phase 2)
> **Status**: design only. Phase 1 ships no expiry timer service, no
> Preferences key, and no `WHERE trashedAt < now() - retentionMs` query.
> Preferences key, and no `WHERE deletedAt < now() - retentionMs` query.
> Trashed entries persist until the user runs an explicit
> `permanentDelete` (or the startup orphan sweep collects an
> already-deleted entry's residual blob). The 30-day window below is
@@ -678,7 +678,7 @@ All soft deletes are implemented via the `trashedAt` timestamp, without physical
By default trashed entries are cleaned up after 30 days (lifecycle service timer); the user may configure the days or disable it in Preferences.
Query: `WHERE trashedAt < now() - retentionMs` → batch permanentDelete.
Query: `WHERE deletedAt < now() - retentionMs` → batch permanentDelete.
### 6.3 Edge Cases
@@ -1114,7 +1114,7 @@ The reverse index of DanglingCache (`Map<path, Set<entryId>>`) is built via a si
```sql
SELECT id, externalPath FROM file_entry
WHERE origin = 'external' AND trashedAt IS NULL
WHERE origin = 'external' AND deletedAt IS NULL
```
**No stat performed**—the state field (`Map<entryId, DanglingState>`) is initially empty; lazy stat on query (see §11).
@@ -1291,13 +1291,13 @@ Timing for changes to `pathToEntryIds` (fully self-governed inside file_module,
| Event | Action |
|---|---|
| Startup `initFromDb()` | `SELECT id, externalPath FROM file_entry WHERE origin='external' AND trashedAt IS NULL` → batch add |
| Startup `initFromDb()` | `SELECT id, externalPath FROM file_entry WHERE origin='external' AND deletedAt IS NULL` → batch add |
| `ensureExternalEntry` creates new | addEntry(id, path) |
| `ensureExternalEntry` reuses (upsert hit) | No change (path already indexed) |
| `permanentDelete(external)` | removeEntry(id, path) |
| `rename(external)` (explicit user action) | removeEntry(id, oldPath) + addEntry(id, newPath) |
External entries cannot be trashed (`fe_external_no_trash` CHECK enforces this
External entries cannot be trashed (`fe_external_no_delete` CHECK enforces this
at the schema level; `trash` / `restore` throw at the entry layer before
reaching the reverse-index update). Earlier drafts listed `restore(external)`
and `trash(external)` rows here — they were dead branches and have been
@@ -1411,7 +1411,7 @@ These thresholds are heuristic starting points — tune based on real-world tele
| **Content hash algorithm** | xxhash-h64 | Optimal cost-performance for non-cryptographic scenarios (~20GB/s). 64-bit collision space is sufficient for distinguishing successive versions within a single file's write history — the `xxhash-wasm` package shipped in this version exposes only h32 / h64, and h64 is the strongest variant available; revisit if a 128-bit variant becomes a dependency-cost tradeoff worth taking. |
| **Does write carry version** | Split into write / writeIfUnchanged | Force the caller to explicitly choose; avoid silent degradation to blind write when version is forgotten |
| **Atomic write fsync** | On by default | Correctness guarantee takes precedence over performance; Cherry is not a high-throughput scenario |
| **Trash model** | trashedAt timestamp | parentId unchanged; naturally supports expiry; no system_trash entries |
| **Trash model** | deletedAt timestamp | parentId unchanged; naturally supports expiry; no system_trash entries |
| **pending_fs_ops** | Removed | After extreme simplification, orphan sweep suffices to cover crashes |
| **Startup dangling probe** | Removed | Changed to lazy + Promise.all; stat only when an IPC caller explicitly requests dangling state |
| **Is Watcher a lifecycle service** | No | DirectoryWatcher is a primitive; business modules `new` it via the factory; file_module doesn't actively watch |
@@ -1441,7 +1441,7 @@ This checklist is the canonical addition procedure. A PR introducing a new origi
| Location | Change required |
|---|---|
| `src/main/data/db/schemas/file.ts` | Review every CHECK constraint naming `origin``fe_origin_consistency`, `fe_size_internal_only`, `fe_external_no_trash`, `fe_external_path_unique`, etc. — and decide whether the new variant honors / violates / is exempt from each |
| `src/main/data/db/schemas/file.ts` | Review every CHECK constraint naming `origin``fe_origin_consistency`, `fe_size_internal_only`, `fe_external_no_delete`, `fe_external_path_unique`, etc. — and decide whether the new variant honors / violates / is exempt from each |
| Drizzle migration | Ship the constraint updates in the same migration as the enum expansion. Partial unique indexes on `externalPath` may need a new branch |
| Existing rows | No migration should run for existing rows unless the new variant has a natural subset mapping (unlikely) |
@@ -1458,7 +1458,7 @@ Every ad-hoc `if (entry.origin === 'internal')` / `=== 'external'` in the codeba
| Policy | Location |
|---|---|
| Trash-ability (who can soft-delete) | `trash` / `restore` in FileManager; DB CHECK `fe_external_no_trash` |
| Trash-ability (who can soft-delete) | `trash` / `restore` in FileManager; DB CHECK `fe_external_no_delete` |
| Size snapshot storage | `write` / `writeIfUnchanged` internal-DB-update branch; `toFileInfo` projection |
| Name / ext as SoT vs projection | `rename` mutation; `toFileInfo` projection; `FileEntrySchema` field docs |
| DanglingCache participation | `DanglingCache.check` returns `'present'` for internal; consider where the new variant falls on the `present/missing/unknown` axis |

View File

@@ -0,0 +1,36 @@
-- MANUAL PATCH: workaround for drizzle-orm/drizzle-kit #3653
-- (rebuild-table path drops the leading ALTER TABLE RENAME COLUMN statement,
-- so the INSERT ... SELECT below references "deleted_at" on the old table
-- which still has the column named "trashed_at" and would fail with
-- "no such column: deleted_at". Fix per yume-chan's patch suggestion.)
-- https://github.com/drizzle-team/drizzle-orm/issues/3653
ALTER TABLE `file_entry` RENAME COLUMN `trashed_at` TO `deleted_at`;--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_file_entry` (
`id` text PRIMARY KEY NOT NULL,
`origin` text NOT NULL,
`name` text NOT NULL,
`ext` text,
`size` integer,
`external_path` text,
`deleted_at` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
CONSTRAINT "fe_origin_check" CHECK("__new_file_entry"."origin" IN ('internal', 'external')),
CONSTRAINT "fe_origin_consistency" CHECK(("__new_file_entry"."origin" = 'internal' AND "__new_file_entry"."external_path" IS NULL) OR ("__new_file_entry"."origin" = 'external' AND "__new_file_entry"."external_path" IS NOT NULL)),
CONSTRAINT "fe_external_no_delete" CHECK("__new_file_entry"."origin" != 'external' OR "__new_file_entry"."deleted_at" IS NULL),
CONSTRAINT "fe_size_internal_only" CHECK(("__new_file_entry"."origin" = 'internal' AND "__new_file_entry"."size" IS NOT NULL AND "__new_file_entry"."size" >= 0) OR ("__new_file_entry"."origin" = 'external' AND "__new_file_entry"."size" IS NULL))
);
--> statement-breakpoint
INSERT INTO `__new_file_entry`("id", "origin", "name", "ext", "size", "external_path", "deleted_at", "created_at", "updated_at") SELECT "id", "origin", "name", "ext", "size", "external_path", "deleted_at", "created_at", "updated_at" FROM `file_entry`;--> statement-breakpoint
DROP TABLE `file_entry`;--> statement-breakpoint
ALTER TABLE `__new_file_entry` RENAME TO `file_entry`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `fe_deleted_at_idx` ON `file_entry` (`deleted_at`);--> statement-breakpoint
CREATE INDEX `fe_created_at_idx` ON `file_entry` (`created_at`);--> statement-breakpoint
-- Functional index on `lower(external_path)`. drizzle-kit's rebuild path
-- wraps the whole expression in backticks (`lower("external_path")`) which
-- SQLite reads as a single quoted identifier instead of a function call;
-- preserve the original 0023 syntax — bare `lower("external_path")` — by hand.
CREATE UNIQUE INDEX `fe_external_path_lower_unique_idx` ON `file_entry` (lower("external_path"));--> statement-breakpoint
CREATE INDEX `fe_external_path_idx` ON `file_entry` (`external_path`);

File diff suppressed because it is too large Load Diff

View File

@@ -182,6 +182,13 @@
"when": 1779183817228,
"tag": "0025_left_black_tarantula",
"breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1779373185863,
"tag": "0026_sturdy_aqueduct",
"breakpoints": true
}
],
"version": "7"

View File

@@ -21,7 +21,7 @@ describe('ListFilesQuerySchema', () => {
})
it('rejects the impossible combo inTrash=true && origin=external (S6 refine)', () => {
// DB CHECK fe_external_no_trash makes this combo always return zero
// DB CHECK fe_external_no_delete makes this combo always return zero
// rows; the refine surfaces the contradiction at the parse boundary
// instead of silently returning empty results to callers.
const result = ListFilesQuerySchema.safeParse({ inTrash: true, origin: 'external' })

View File

@@ -89,7 +89,7 @@ export const ListFilesQuerySchema = z
})
.refine(
(q) => !(q.inTrash === true && q.origin === 'external'),
'inTrash=true is incompatible with origin=external — external entries cannot be trashed (DB CHECK fe_external_no_trash)'
'inTrash=true is incompatible with origin=external — external entries cannot be trashed (DB CHECK fe_external_no_delete)'
)
export type ListFilesQueryParams = z.input<typeof ListFilesQuerySchema>
export type ListFilesQuery = z.output<typeof ListFilesQuerySchema>
@@ -127,7 +127,7 @@ export type FileSchemas = {
* Trash + origin caveat: the combination `inTrash=true & origin='external'`
* is rejected by the schema (`ListFilesQuerySchema` `.refine` rule),
* because external rows are constrained by the DB CHECK
* `fe_external_no_trash` to always have `trashedAt = NULL` and would
* `fe_external_no_delete` to always have `deletedAt = NULL` and would
* otherwise return an empty result with no error signal. Modelling the
* query as a discriminated union (`{ origin: 'external'; inTrash?: false } |
* { origin?: 'internal'; inTrash?: boolean }`) is a follow-up worth doing

View File

@@ -9,7 +9,7 @@ const TS = 1700000000000
// After the BO/DB split, each variant's schema declares only its own fields
// (strictObject — extra keys are rejected). Internal has no `externalPath` and
// `trashedAt` is optional; external has no `size` and no `trashedAt`. Factories
// `deletedAt` is optional; external has no `size` and no `deletedAt`. Factories
// match that shape so callers only set fields that belong on the arm.
function makeInternal(overrides: Record<string, unknown> = {}) {
return {
@@ -179,24 +179,24 @@ describe('FileEntrySchema origin invariants', () => {
// ─── Trash ───
describe('FileEntrySchema trash (trashedAt)', () => {
// After I19 the internal arm types `trashedAt` as `optional number` — present
describe('FileEntrySchema trash (deletedAt)', () => {
// After I19 the internal arm types `deletedAt` as `optional number` — present
// when trashed, absent (undefined) when live. The external arm drops the
// field entirely (the DB CHECK fe_external_no_trash already forbids it).
// field entirely (the DB CHECK fe_external_no_delete already forbids it).
it('accepts active internal entry (trashedAt absent)', () => {
it('accepts active internal entry (deletedAt absent)', () => {
expect(FileEntrySchema.safeParse(makeInternal()).success).toBe(true)
})
it('accepts trashed internal entry', () => {
expect(FileEntrySchema.safeParse(makeInternal({ trashedAt: TS })).success).toBe(true)
expect(FileEntrySchema.safeParse(makeInternal({ deletedAt: TS })).success).toBe(true)
})
it('rejects trashed external entry (strictObject — external has no trashedAt field)', () => {
expect(FileEntrySchema.safeParse(makeExternal({ trashedAt: TS })).success).toBe(false)
it('rejects trashed external entry (strictObject — external has no deletedAt field)', () => {
expect(FileEntrySchema.safeParse(makeExternal({ deletedAt: TS })).success).toBe(false)
})
it('accepts external entry (no trashedAt field by construction)', () => {
it('accepts external entry (no deletedAt field by construction)', () => {
expect(FileEntrySchema.safeParse(makeExternal()).success).toBe(true)
})
})

View File

@@ -28,7 +28,7 @@
* | `ext` | SoT | derived from `externalPath` extname (stable) |
* | `size` | SoT (bytes, ≥ 0) | **absent** — live value via `getMetadata` |
* | `externalPath`| **absent** | non-null absolute path (canonical) |
* | `trashedAt` | optional (present iff trashed) | **absent** (external cannot be trashed) |
* | `deletedAt` | optional (present iff trashed) | **absent** (external cannot be trashed) |
*
* "Absent" means the field is not declared on that variant's schema at all —
* `entry.size` is a type error on the external arm, not `null` you have to
@@ -86,11 +86,11 @@
* (update in place via rename / write)
* ```
*
* - Active: `trashedAt` is absent — on `InternalEntrySchema` it's `optional`
* - Active: `deletedAt` is absent — on `InternalEntrySchema` it's `optional`
* so omitted means live; `ExternalEntrySchema` doesn't declare the
* field at all and the DB `fe_external_no_trash` CHECK enforces it
* field at all and the DB `fe_external_no_delete` CHECK enforces it
* at the row layer
* - Trashed: `trashedAt = <ms epoch>` (internal-only)
* - Trashed: `deletedAt = <ms epoch>` (internal-only)
* - permanentDelete on internal: unlink FS file + delete DB row
* - permanentDelete on external: **DB row only** — the physical file is left
* untouched. Entry-level deletion is decoupled from physical deletion;
@@ -203,15 +203,15 @@ export type CanonicalFilePath = FilePath & CanonicalExternalPath
// ## DB row vs Business Object
//
// The `file_entry` SQLite table is a flat row with all columns physically
// present (size / externalPath / trashedAt are all nullable on the column
// present (size / externalPath / deletedAt are all nullable on the column
// level), guarded by three CHECK constraints (`fe_origin_consistency`,
// `fe_size_internal_only`, `fe_external_no_trash`) so a row can never
// `fe_size_internal_only`, `fe_external_no_delete`) so a row can never
// represent an impossible combination. That is the **DB-row** layer.
//
// `FileEntry` is the **business object** consumers actually work with.
// Discrimination on `origin` means an internal entry doesn't *have* an
// `externalPath`, and an external entry doesn't *have* a `size` /
// `trashedAt` — these fields are simply absent on the BO shape, not `null`.
// `deletedAt` — these fields are simply absent on the BO shape, not `null`.
// Narrowing on `origin` gives TS the right keys at the right callsite,
// so renderer code never has to `if (entry.origin === 'internal') ...`
// just to access `entry.size`, and never has to `as` a `null` check away.
@@ -247,7 +247,7 @@ const CommonEntryFields = {
/**
* Internal entry — Cherry owns the content at `{userData}/Data/Files/{id}.{ext}`.
*
* Variant-only fields: `size` (authoritative byte count), `trashedAt`
* Variant-only fields: `size` (authoritative byte count), `deletedAt`
* (optional, present and non-null when entry is trashed). `externalPath`
* is absent on this variant — there is no user-provided path. The DB row
* carries `externalPath: null` to satisfy the table schema; the BO
@@ -264,20 +264,20 @@ export const InternalEntrySchema = z.strictObject({
/**
* Trash timestamp (ms epoch). Optional — present and non-null when the
* entry is in the trash, absent when it is live. Internal entries are the
* only ones that can be trashed (`fe_external_no_trash` CHECK).
* only ones that can be trashed (`fe_external_no_delete` CHECK).
*/
trashedAt: TimestampSchema.optional()
deletedAt: TimestampSchema.optional()
})
/**
* External entry — Cherry references a user-provided path.
*
* Variant-only field: `externalPath` (absolute, canonical). `size` and
* `trashedAt` are absent on this variant — external files may change
* `deletedAt` are absent on this variant — external files may change
* outside Cherry at any time so no DB size snapshot is kept (live values
* come from File IPC `getMetadata`), and external entries cannot be
* trashed (`fe_external_no_trash` CHECK). The DB row carries `size: null`
* and `trashedAt: null` to satisfy the table schema; the BO dispatcher
* trashed (`fe_external_no_delete` CHECK). The DB row carries `size: null`
* and `deletedAt: null` to satisfy the table schema; the BO dispatcher
* drops them.
*/
export const ExternalEntrySchema = z.strictObject({

View File

@@ -274,7 +274,7 @@ export interface FileIpcApi {
* Idempotent by design — callers holding an `externalPath` can invoke this
* freely without pre-checking. The global unique index
* `UNIQUE(externalPath)` (internal rows are `null` and exempt) enforces the
* invariant; `fe_external_no_trash` forbids trashed external rows so no
* invariant; `fe_external_no_delete` forbids trashed external rows so no
* "restore" branch exists.
*
* @phase 2 — not yet wired
@@ -398,9 +398,9 @@ export interface FileIpcApi {
// Section status: all `@phase 2`.
/**
* Move entry to Trash (soft delete via trashedAt). Internal-origin entries only.
* Move entry to Trash (soft delete via deletedAt). Internal-origin entries only.
* Passing an external-origin entry id throws: external entries cannot be trashed
* (`fe_external_no_trash` CHECK).
* (`fe_external_no_delete` CHECK).
*
* @phase 2 — not yet wired
*/

View File

@@ -29,7 +29,7 @@ describe('fileHandlers (DataApi)', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now,
...overrides
@@ -41,7 +41,7 @@ describe('fileHandlers (DataApi)', () => {
await Promise.all([
seedEntry('019606a0-0000-7000-8000-000000000a01'),
seedEntry('019606a0-0000-7000-8000-000000000a02'),
seedEntry('019606a0-0000-7000-8000-000000000a03', { trashedAt: Date.now() })
seedEntry('019606a0-0000-7000-8000-000000000a03', { deletedAt: Date.now() })
])
const result = (await fileHandlers['/files/entries'].GET({ query: {} } as never)) as {

View File

@@ -30,7 +30,7 @@ function baseInternal(overrides: Record<string, unknown> = {}) {
ext: 'md',
size: 100,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: TS,
updatedAt: TS,
...overrides
@@ -45,7 +45,7 @@ function baseExternal(path: string, overrides: Record<string, unknown> = {}) {
ext: 'pdf',
size: null,
externalPath: path,
trashedAt: null,
deletedAt: null,
createdAt: TS,
updatedAt: TS,
...overrides
@@ -104,17 +104,17 @@ describe('fileEntryTable — functional unique index on lower(externalPath)', ()
})
})
describe('fileEntryTable — fe_external_no_trash check', () => {
describe('fileEntryTable — fe_external_no_delete check', () => {
const dbh = setupTestDatabase()
it('rejects an external entry with non-null trashedAt', async () => {
it('rejects an external entry with non-null deletedAt', async () => {
await expect(
dbh.db.insert(fileEntryTable).values(baseExternal('/Users/me/will-not-trash.pdf', { trashedAt: TS }))
dbh.db.insert(fileEntryTable).values(baseExternal('/Users/me/will-not-trash.pdf', { deletedAt: TS }))
).rejects.toThrow()
})
it('allows internal entries to be trashed', async () => {
await expect(dbh.db.insert(fileEntryTable).values(baseInternal({ trashedAt: TS }))).resolves.not.toThrow()
await expect(dbh.db.insert(fileEntryTable).values(baseInternal({ deletedAt: TS }))).resolves.not.toThrow()
})
})

View File

@@ -1,7 +1,12 @@
import { sql } from 'drizzle-orm'
import { check, index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { createUpdateTimestamps, uuidPrimaryKey, uuidPrimaryKeyOrdered } from './_columnHelpers'
import {
createUpdateDeleteTimestamps,
createUpdateTimestamps,
uuidPrimaryKey,
uuidPrimaryKeyOrdered
} from './_columnHelpers'
/**
* NOTE: `file_upload` (AI provider upload cache) is intentionally NOT included
@@ -46,23 +51,18 @@ export const fileEntryTable = sqliteTable(
/** Absolute path to the user-provided file. Non-null iff origin='external' */
externalPath: text(),
// ─── Trash ───
/**
* Non-null = trashed (ms epoch). Internal-only.
*
* External entries cannot be trashed (enforced by `fe_external_no_trash`
* check constraint). Their lifecycle is monotonic: create via
* `ensureExternalEntry`, update in place, or remove immediately via
* `permanentDelete` (DB-only — the physical file is left untouched;
* path-level deletion is a separate, explicit unmanaged `@main/utils/file/fs.remove(path)`).
*/
trashedAt: integer(),
// ─── Timestamps ───
...createUpdateTimestamps
// `deletedAt` is soft-delete (NULL = not deleted). Internal-only —
// external entries cannot be soft-deleted (enforced by
// `fe_external_no_delete`); their lifecycle is monotonic: create via
// `ensureExternalEntry`, update in place, or remove immediately via
// `permanentDelete` (DB-only — the physical file is left untouched;
// path-level deletion is a separate, explicit unmanaged
// `@main/utils/file/fs.remove(path)`).
...createUpdateDeleteTimestamps
},
(t) => [
index('fe_trashed_at_idx').on(t.trashedAt),
index('fe_deleted_at_idx').on(t.deletedAt),
index('fe_created_at_idx').on(t.createdAt),
// Case-insensitive uniqueness for `externalPath`. SQLite indexes
// expressions verbatim, so this index covers both the uniqueness
@@ -99,7 +99,7 @@ export const fileEntryTable = sqliteTable(
// External entries cannot be trashed — trash/restore is internal-only.
// External removal is always immediate via permanentDelete (DB-only; the
// physical file is left untouched, path-level @main/utils/file/fs.remove is a separate call).
check('fe_external_no_trash', sql`${t.origin} != 'external' OR ${t.trashedAt} IS NULL`),
check('fe_external_no_delete', sql`${t.origin} != 'external' OR ${t.deletedAt} IS NULL`),
// Size semantics are origin-dependent: internal rows carry an authoritative
// byte count (non-null, ≥ 0); external rows must leave size NULL and read
// live values from File IPC `getMetadata`. The Zod layer rejects the same

View File

@@ -41,7 +41,7 @@ export interface CreateFileEntryRow {
readonly size: number | null
/** Non-null iff `origin === 'external'`; must be pre-canonicalized. */
readonly externalPath: string | null
readonly trashedAt?: number | null
readonly deletedAt?: number | null
}
/**
@@ -50,7 +50,7 @@ export interface CreateFileEntryRow {
* byte count atomically; on external rows it must remain `null`.
*/
export type UpdateFileEntryRow = Partial<Pick<CreateFileEntryRow, 'name' | 'ext' | 'size'>> & {
readonly trashedAt?: number | null
readonly deletedAt?: number | null
}
export interface FindEntriesQuery {
@@ -200,11 +200,11 @@ function rowToFileEntry(row: FileEntryRow): FileEntry {
name: row.name,
ext: row.ext,
size: row.size,
// trashedAt is `optional` on the BO — present iff the DB column is
// deletedAt is `optional` on the BO — present iff the DB column is
// non-null. Bypass `nullsToUndefined` so we don't pull in a helper
// whose project-wide meaning is "every null becomes undefined";
// here only this specific column flips.
...(row.trashedAt !== null ? { trashedAt: row.trashedAt } : {}),
...(row.deletedAt !== null ? { deletedAt: row.deletedAt } : {}),
createdAt: row.createdAt,
updatedAt: row.updatedAt
})
@@ -271,9 +271,9 @@ class FileEntryServiceImpl implements FileEntryService {
conditions.push(eq(fileEntryTable.origin, query.origin))
}
if (query.inTrash === true) {
conditions.push(isNotNull(fileEntryTable.trashedAt))
conditions.push(isNotNull(fileEntryTable.deletedAt))
} else {
conditions.push(isNull(fileEntryTable.trashedAt))
conditions.push(isNull(fileEntryTable.deletedAt))
}
let queryBuilder = this.getDb()
@@ -300,9 +300,9 @@ class FileEntryServiceImpl implements FileEntryService {
conditions.push(eq(fileEntryTable.origin, query.origin))
}
if (query.inTrash === true) {
conditions.push(isNotNull(fileEntryTable.trashedAt))
conditions.push(isNotNull(fileEntryTable.deletedAt))
} else {
conditions.push(isNull(fileEntryTable.trashedAt))
conditions.push(isNull(fileEntryTable.deletedAt))
}
const where = and(...conditions)
@@ -343,7 +343,7 @@ class FileEntryServiceImpl implements FileEntryService {
}
async findUnreferenced(query: { origin?: FileEntryOrigin } = {}): Promise<FileEntry[]> {
const conditions: SQL[] = [isNull(fileEntryTable.trashedAt), isNull(fileRefTable.id)]
const conditions: SQL[] = [isNull(fileEntryTable.deletedAt), isNull(fileRefTable.id)]
if (query.origin) conditions.push(eq(fileEntryTable.origin, query.origin))
const rows = await this.getDb()
.select({ entry: fileEntryTable })
@@ -371,7 +371,7 @@ class FileEntryServiceImpl implements FileEntryService {
ext: values.ext,
size: values.size,
externalPath: values.externalPath,
trashedAt: values.trashedAt ?? null,
deletedAt: values.deletedAt ?? null,
createdAt: now,
updatedAt: now
})
@@ -392,7 +392,7 @@ class FileEntryServiceImpl implements FileEntryService {
if (values.name !== undefined) updates.name = values.name
if (values.ext !== undefined) updates.ext = values.ext
if (values.size !== undefined) updates.size = values.size
if (values.trashedAt !== undefined) updates.trashedAt = values.trashedAt
if (values.deletedAt !== undefined) updates.deletedAt = values.deletedAt
const rows = await this.getDb().update(fileEntryTable).set(updates).where(eq(fileEntryTable.id, id)).returning()
if (rows.length === 0) {
throw DataApiErrorFactory.notFound('FileEntry', id)

View File

@@ -31,7 +31,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 11,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -74,14 +74,14 @@ describe('FileEntryService', () => {
ext: 'md',
size: 0,
externalPath: null,
trashedAt: now,
deletedAt: now,
createdAt: now,
updatedAt: now
})
const entry = await fileEntryService.findById(id)
if (entry?.origin === 'internal') {
expect(entry.trashedAt).toBe(now)
expect(entry.deletedAt).toBe(now)
} else {
throw new Error('expected internal entry')
}
@@ -99,7 +99,7 @@ describe('FileEntryService', () => {
ext: 'pdf',
size: null,
externalPath: '/Users/me/doc.pdf',
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -124,7 +124,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: null,
externalPath: '/Users/me/a.txt',
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -149,7 +149,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: null,
externalPath: '/Users/me/A.TXT',
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -173,7 +173,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: null,
externalPath: '/Users/me/A.TXT',
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -190,7 +190,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: null,
externalPath: '/Users/me/a.txt',
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -215,7 +215,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
},
@@ -226,7 +226,7 @@ describe('FileEntryService', () => {
ext: 'md',
size: 2,
externalPath: null,
trashedAt: now,
deletedAt: now,
createdAt: now,
updatedAt: now
}
@@ -247,7 +247,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
},
@@ -258,7 +258,7 @@ describe('FileEntryService', () => {
ext: 'pdf',
size: null,
externalPath: '/foo/e.pdf',
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
}
@@ -279,7 +279,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
},
@@ -290,7 +290,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: now,
deletedAt: now,
createdAt: now,
updatedAt: now
}
@@ -310,7 +310,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: i,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now + i,
updatedAt: now + i
}))
@@ -331,7 +331,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: i + 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now + i,
updatedAt: now + i
}))
@@ -348,7 +348,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
},
@@ -359,7 +359,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 2,
externalPath: null,
trashedAt: now,
deletedAt: now,
createdAt: now,
updatedAt: now
}
@@ -409,7 +409,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now + 2,
updatedAt: now + 2
},
@@ -420,7 +420,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
},
@@ -431,7 +431,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now + 1,
updatedAt: now + 1
}
@@ -473,7 +473,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: sharedTs,
updatedAt: sharedTs
}))
@@ -513,7 +513,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: sharedTs,
updatedAt: sharedTs
}))
@@ -628,16 +628,16 @@ describe('FileEntryService', () => {
})
})
it('updates trashedAt for soft delete', async () => {
it('updates deletedAt for soft delete', async () => {
const id = '019606a0-0000-7000-8000-000000000b02' as FileEntryId
await fileEntryService.create({ id, origin: 'internal', name: 'tmp', ext: 'txt', size: 1, externalPath: null })
const trashedAt = Date.now()
const updated = await fileEntryService.update(id, { trashedAt })
const deletedAt = Date.now()
const updated = await fileEntryService.update(id, { deletedAt })
if (updated.origin !== 'internal') throw new Error('expected internal entry')
expect(updated.trashedAt).toBe(trashedAt)
expect(updated.deletedAt).toBe(deletedAt)
})
it('throws when setting trashedAt on an external row (CHECK fe_external_no_trash)', async () => {
it('throws when setting deletedAt on an external row (CHECK fe_external_no_delete)', async () => {
const id = '019606a0-0000-7000-8000-000000000b03' as FileEntryId
await fileEntryService.create({
id,
@@ -647,7 +647,7 @@ describe('FileEntryService', () => {
size: null,
externalPath: '/x/y.txt'
})
await expect(fileEntryService.update(id, { trashedAt: Date.now() })).rejects.toThrow()
await expect(fileEntryService.update(id, { deletedAt: Date.now() })).rejects.toThrow()
})
it('rejects unsafe name BEFORE the SQL UPDATE commits', async () => {
@@ -671,7 +671,7 @@ describe('FileEntryService', () => {
// listAllIds backs the Phase 1b.4 startup disk scan, which decides which
// on-disk UUID files are orphaned (no DB row, regardless of trashed
// state). The implementation is one query — the regressions worth
// catching are misclassifying trashed rows as deleted (trashedAt filter
// catching are misclassifying trashed rows as deleted (deletedAt filter
// creeping in) or returning an array shape.
it('returns an empty Set on an empty table', async () => {
@@ -698,7 +698,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: Date.now()
deletedAt: Date.now()
})
const ids = await fileEntryService.listAllIds()
@@ -944,7 +944,7 @@ describe('FileEntryService', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: Date.now()
deletedAt: Date.now()
})
const result = await fileEntryService.findUnreferenced()

View File

@@ -28,7 +28,7 @@ describe('FileRefService', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})

View File

@@ -91,7 +91,7 @@
* **External entries cannot be trashed.** Their lifecycle is monotonic:
* created by `ensureExternalEntry` (pure upsert keyed by path — see below),
* updated in place via `write` / `rename`, and removed only by an explicit
* (non-UI) `permanentDelete`. The `fe_external_no_trash` CHECK constraint
* (non-UI) `permanentDelete`. The `fe_external_no_delete` CHECK constraint
* enforces this at the DB level; `trash` / `restore` on an external entry id
* will throw.
*
@@ -375,7 +375,7 @@ export interface IFileManager {
* The global unique index `UNIQUE(externalPath)` (internal rows have
* `externalPath = null` and are exempt — SQLite treats NULLs as distinct)
* guarantees at most one row per path. External entries cannot be trashed
* (`fe_external_no_trash` CHECK), so no "restore" branch is possible.
* (`fe_external_no_delete` CHECK), so no "restore" branch is possible.
* Repeated calls with the same path are safe and idempotent.
*/
ensureExternalEntry(params: EnsureExternalEntryParams): Promise<FileEntry>
@@ -471,17 +471,17 @@ export interface IFileManager {
// ─── Trash / Delete ───
/**
* Move entry to Trash (soft delete via `trashedAt`). Internal-only.
* Move entry to Trash (soft delete via `deletedAt`). Internal-only.
*
* Passing an external entry id throws: external entries cannot be trashed
* (enforced by the `fe_external_no_trash` CHECK constraint). Business layers
* (enforced by the `fe_external_no_delete` CHECK constraint). Business layers
* should call `permanentDelete` on external entries if the user really wants
* the reference gone.
*/
trash(id: FileEntryId): Promise<void>
/**
* Restore entry from Trash (`trashedAt = null`). Internal-only — external
* Restore entry from Trash (`deletedAt = null`). Internal-only — external
* entries are never trashed, so passing one throws (the entry is already
* active by definition).
*/

View File

@@ -57,7 +57,7 @@ describe('FileManager (integration)', () => {
ext: 'txt',
size: 'internal-payload'.length,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -93,7 +93,7 @@ describe('FileManager (integration)', () => {
ext: 'pdf',
size: null,
externalPath: file,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -124,7 +124,7 @@ describe('FileManager (integration)', () => {
ext: 'txt',
size: null,
externalPath: file,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -150,7 +150,7 @@ describe('FileManager (integration)', () => {
ext: 'txt',
size: null,
externalPath: file,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -235,30 +235,30 @@ describe('FileManager (integration)', () => {
await fm.trash(created.id)
const trashed = await fm.getById(created.id)
if (trashed.origin === 'internal') {
expect(typeof trashed.trashedAt).toBe('number')
expect(typeof trashed.deletedAt).toBe('number')
}
const restored = await fm.restore(created.id)
if (restored.origin === 'internal') {
expect(restored.trashedAt).toBeUndefined()
expect(restored.deletedAt).toBeUndefined()
}
await fm.permanentDelete(created.id)
await expect(fm.getById(created.id)).rejects.toThrow(/not found/i)
})
it('INT-5: trash on external entry is blocked by DB CHECK fe_external_no_trash', async () => {
it('INT-5: trash on external entry is blocked by DB CHECK fe_external_no_delete', async () => {
const file = path.join(tmp, 'ext.txt')
await writeFile(file, 'x')
const e = await fm.ensureExternalEntry({ externalPath: file as never })
await expect(fm.trash(e.id)).rejects.toThrow()
// External BO has no `trashedAt` field by construction; if the trash
// attempt had slipped through, the DB CHECK fe_external_no_trash would
// External BO has no `deletedAt` field by construction; if the trash
// attempt had slipped through, the DB CHECK fe_external_no_delete would
// have rejected it, so reading the row back must still surface as
// origin='external' with no trashedAt projection.
// origin='external' with no deletedAt projection.
const refreshed = await fm.getById(e.id)
expect(refreshed.origin).toBe('external')
expect(refreshed).not.toHaveProperty('trashedAt')
expect(refreshed).not.toHaveProperty('deletedAt')
})
it('INT-6: permanentDelete on external leaves user file untouched', async () => {
@@ -283,7 +283,7 @@ describe('FileManager (integration)', () => {
ext: 'txt',
size: 5,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -312,7 +312,7 @@ describe('FileManager (integration)', () => {
ext: 'txt',
size: null,
externalPath: file,
trashedAt: null,
deletedAt: null,
createdAt: 0,
updatedAt: 0
})
@@ -368,7 +368,7 @@ describe('FileManager (integration)', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -508,7 +508,7 @@ describe('FileManager (integration)', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: 0,
updatedAt: 0
})

View File

@@ -44,7 +44,7 @@ describe('toFileInfo', () => {
ext: 'txt',
size: null,
externalPath: file,
trashedAt: null,
deletedAt: null,
createdAt: 1000,
updatedAt: 1000
} as unknown as FileEntry
@@ -72,7 +72,7 @@ describe('toFileInfo', () => {
ext: 'png',
size: bytes.length,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: 1000,
updatedAt: 1000
} as unknown as FileEntry
@@ -92,7 +92,7 @@ describe('toFileInfo', () => {
ext: 'txt',
size: null,
externalPath: path.join(tmp, 'gone.txt'),
trashedAt: null,
deletedAt: null,
createdAt: 1000,
updatedAt: 1000
} as unknown as FileEntry
@@ -111,7 +111,7 @@ describe('toFileInfo', () => {
ext: null,
size: null,
externalPath: file,
trashedAt: null,
deletedAt: null,
createdAt: 1000,
updatedAt: 1000
} as unknown as FileEntry

View File

@@ -37,7 +37,7 @@ describe('OrphanRefScanner', () => {
ext: 'txt',
size: 1,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -501,7 +501,7 @@ describe('runStartupFileSweep (FS-level)', () => {
ext: 'txt',
size: 4,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -774,7 +774,7 @@ describe('runStartupFileSweep (FS-level)', () => {
it('preserves trashed entries physical files through the sweep (listAllIds returns active + trashed)', async () => {
// Regression: `FileEntryService.listAllIds` unit test verifies it returns
// active + trashed ids, but no end-to-end test wired it through
// runStartupFileSweep. A regression that filtered `WHERE trashedAt IS
// runStartupFileSweep. A regression that filtered `WHERE deletedAt IS
// NULL` would silently nuke every trashed file's physical blob on next
// boot — data loss the user did not consent to. Pin the contract here.
const trashedId = '019606a0-0000-7000-8000-0000000fa2ed' as FileEntryId
@@ -789,8 +789,8 @@ describe('runStartupFileSweep (FS-level)', () => {
externalPath: null
})
await writeFile(trashedPath, 'x')
// 2) Move to trash via the service (sets trashedAt; row stays in DB).
await fileEntryService.update(trashedId, { trashedAt: Date.now() })
// 2) Move to trash via the service (sets deletedAt; row stays in DB).
await fileEntryService.update(trashedId, { deletedAt: Date.now() })
const report = await runStartupFileSweep({ fileEntryService })
expect(report.outcome).toBe('completed')

View File

@@ -72,7 +72,7 @@ describe('internal/content/hash', () => {
ext: 'txt',
size: null,
externalPath: file,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -99,7 +99,7 @@ describe('internal/content/hash', () => {
ext: 'txt',
size: null,
externalPath: fileA,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
},
@@ -110,7 +110,7 @@ describe('internal/content/hash', () => {
ext: 'txt',
size: null,
externalPath: fileB,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
}
@@ -130,7 +130,7 @@ describe('internal/content/hash', () => {
ext: 'txt',
size: null,
externalPath: file,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})

View File

@@ -72,7 +72,7 @@ describe('internal/content/read', () => {
ext: 'txt',
size: null,
externalPath: file,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -96,7 +96,7 @@ describe('internal/content/read', () => {
ext: 'png',
size: null,
externalPath: file,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -119,7 +119,7 @@ describe('internal/content/read', () => {
ext: 'pdf',
size: null,
externalPath: file,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})
@@ -144,7 +144,7 @@ describe('internal/content/read', () => {
ext: 'txt',
size: null,
externalPath: file,
trashedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now
})

View File

@@ -294,7 +294,7 @@ describe('internal/entry/create.createInternal', () => {
ext: 'txt',
size: null,
externalPath: phantomCaseDifferent,
trashedAt: null,
deletedAt: null,
createdAt: Date.now(),
updatedAt: Date.now()
})

View File

@@ -86,30 +86,30 @@ describe('internal/entry/lifecycle', () => {
const id = await makeInternal()
await trash(deps, id)
const entry = await fileEntryService.getById(id)
// trashedAt is an `optional` ms-epoch number on internal entries —
// deletedAt is an `optional` ms-epoch number on internal entries —
// present + non-zero when trashed, absent (undefined) when live.
expect(entry.origin).toBe('internal')
if (entry.origin === 'internal') {
expect(typeof entry.trashedAt).toBe('number')
expect(typeof entry.deletedAt).toBe('number')
}
})
it('throws when called on an external entry (CHECK fe_external_no_trash)', async () => {
it('throws when called on an external entry (CHECK fe_external_no_delete)', async () => {
const id = await makeExternal()
await expect(trash(deps, id)).rejects.toThrow()
})
})
describe('restore', () => {
it('clears trashedAt on a trashed internal entry', async () => {
it('clears deletedAt on a trashed internal entry', async () => {
const id = await makeInternal()
await trash(deps, id)
await restore(deps, id)
const entry = await fileEntryService.getById(id)
// After restore, trashedAt is absent (undefined) on the BO.
// After restore, deletedAt is absent (undefined) on the BO.
expect(entry.origin).toBe('internal')
if (entry.origin === 'internal') {
expect(entry.trashedAt).toBeUndefined()
expect(entry.deletedAt).toBeUndefined()
}
})

View File

@@ -2,7 +2,7 @@
* Entry lifecycle — trash / restore / permanentDelete + batch variants.
*
* `trash` / `restore` are internal-only; passing an external id throws (the
* `fe_external_no_trash` CHECK enforces this at the DB level for `trash`, and
* `fe_external_no_delete` CHECK enforces this at the DB level for `trash`, and
* `restore` uses an explicit early-throw because trashed external rows cannot
* exist by definition).
*
@@ -26,7 +26,7 @@ import type { FileManagerDeps } from '../deps'
const logger = loggerService.withContext('internal/entry/lifecycle')
export async function trash(deps: FileManagerDeps, id: FileEntryId): Promise<void> {
await deps.fileEntryService.update(id, { trashedAt: Date.now() })
await deps.fileEntryService.update(id, { deletedAt: Date.now() })
}
export async function restore(deps: FileManagerDeps, id: FileEntryId): Promise<FileEntry> {
@@ -34,7 +34,7 @@ export async function restore(deps: FileManagerDeps, id: FileEntryId): Promise<F
if (entry.origin === 'external') {
throw new Error(`restore: external entry ${id} cannot be trashed by definition; nothing to restore`)
}
return deps.fileEntryService.update(id, { trashedAt: null })
return deps.fileEntryService.update(id, { deletedAt: null })
}
export async function permanentDelete(deps: FileManagerDeps, id: FileEntryId): Promise<void> {

View File

@@ -80,7 +80,7 @@ describe('createDirectoryWatcher', () => {
name: 'note',
ext: 'txt',
size: null,
trashedAt: null,
deletedAt: null,
createdAt: 0,
updatedAt: 0
} as never)
@@ -107,7 +107,7 @@ describe('createDirectoryWatcher', () => {
name: 'gone',
ext: 'txt',
size: null,
trashedAt: null,
deletedAt: null,
createdAt: 0,
updatedAt: 0
} as never)
@@ -188,7 +188,7 @@ describe('createDirectoryWatcher', () => {
name: 'qué',
ext: 'txt',
size: null,
trashedAt: null,
deletedAt: null,
createdAt: 0,
updatedAt: 0
} as never)

View File

@@ -37,7 +37,7 @@
- **`createInternalEntry` / `ensureExternalEntry` IPC** 在 main 侧一次性完成 "FS 落地 + DB 登记"internal或 "upsert + stat 验证"external成功/失败整体可见
- Internal拷贝内容到 `{userData}/files/{id}.{ext}` 后 insert `file_entry`
- External`externalPath` 纯 upsert同 path 有行就 reuse + 刷 snapshot否则新建external 不进入 trashed 生命周期,`fe_external_no_trash` CHECK 在 schema 层兜底
- External`externalPath` 纯 upsert同 path 有行就 reuse + 刷 snapshot否则新建external 不进入 trashed 生命周期,`fe_external_no_delete` CHECK 在 schema 层兜底
- Renderer **没有**独立登记 entry 的入口——`addFile` 这类绕路被类型系统封闭
**参考**`architecture.md §2.3``createInternalEntry` / `ensureExternalEntry`(及批量版本)、`file-manager-architecture.md §1.2`external path unique

View File

@@ -5,7 +5,7 @@
> 本文档描述的拆分目标("🔀 FileManager.createEntry")捕获的是早期设计,已被后续评审推翻:
>
> - `FileManager.createEntry({origin, content, ...})` → 拆分为 `createInternalEntry`(内容来源 discriminated union+ `ensureExternalEntry`(纯 upsert by path
> - External entry 不进入 trash 生命周期(`fe_external_no_trash` CHECK
> - External entry 不进入 trash 生命周期(`fe_external_no_delete` CHECK
> - `permanentDelete` 对 external 只删 DB 行,物理文件不动
>
> **实现准绳**[`docs/references/file/file-manager-architecture.md`](../../../docs/references/file/file-manager-architecture.md)、[`rfc-file-manager.md`](./rfc-file-manager.md)、[`file-arch-problems-response.md`](./file-arch-problems-response.md)。

View File

@@ -5,7 +5,7 @@
> 本文档捕获的是早期设计,关键术语/决策已被后续评审推翻:
>
> - `FileManager.createEntry({origin})` 已拆分为 `createInternalEntry` + `ensureExternalEntry`A-7
> - External entry 不再进入 trash 生命周期(`fe_external_no_trash` CHECK
> - External entry 不再进入 trash 生命周期(`fe_external_no_delete` CHECK
> - `externalPath` 唯一性由 partial unique 升级为 global unique
> - `permanentDelete` 对 external 只删 DB 行,不触碰物理文件
>

View File

@@ -50,7 +50,7 @@
| 旧 `FileMetadata` 的角色 | v2 对应类型 | 说明 |
| ------------------------ | ---------------- | ---------------------------------------------------------------------------- |
| DB 行 / 持久化身份 | `FileEntry` | 带 `id``origin``trashedAt`;有 lifecycleZod brand 强制走 sanctioned 生产路径 |
| DB 行 / 持久化身份 | `FileEntry` | 带 `id``origin``deletedAt`;有 lifecycleZod brand 强制走 sanctioned 生产路径 |
| 磁盘描述符 / 临时传参 | `FileInfo` | 带 `path``modifiedAt`live view任意构造 |
| 跨边界引用(两者通用) | `FileHandle` | tagged unionIPC 边界首选签名 |
@@ -346,7 +346,7 @@ ORDER BY ref_count DESC
- 删除操作原子性:业务侧只管 file_ref不需要跨表 trigger
- 抗误删:短时间内重新引用该文件不会 fail比如 "undo" 删除一条 message 时)
- 与 internal `trashedAt` 软删的哲学一致延迟、可逆external 虽然没有软删状态,但延迟 orphan 清理仍然提供"重新引用不失败"的好处
- 与 internal `deletedAt` 软删的哲学一致延迟、可逆external 虽然没有软删状态,但延迟 orphan 清理仍然提供"重新引用不失败"的好处
#### 2.3.11 UI 变化
@@ -1155,8 +1155,8 @@ interface FileEntry {
ext: string | null; // 扩展名,**不含前导点**'pdf'),无扩展名时 null
size: number;
externalPath: string | null;
// trashedAt 仅 internal 可非空external 恒为 nullfe_external_no_trash CHECK
trashedAt: number | null;
// deletedAt 仅 internal 可非空external 恒为 nullfe_external_no_delete CHECK
deletedAt: number | null;
createdAt: number;
updatedAt: number;
}
@@ -1478,7 +1478,7 @@ async function migrateFileEntry(oldFile: DexieFileRow): Promise<FileEntryRow> {
oldFile.origin === "external"
? canonicalizeExternalPath(oldFile.path)
: null,
trashedAt: null, // Dexie 没软删除字段external 也不允许 trashedfe_external_no_trash
deletedAt: null, // Dexie 没软删除字段external 也不允许 trashedfe_external_no_delete
createdAt: toMs(oldFile.created_at), // ISO → ms
updatedAt: toMs(oldFile.created_at), // Dexie 无 updatedAt用 createdAt
};

View File

@@ -71,7 +71,7 @@
| `origin` 枚举 | `'internal' \| 'external'` | Cherry 拥有 vs 用户拥有;语义清晰 |
| External path 唯一性 | Global unique index on `externalPath`internal 行为 nullSQLite UNIQUE 视多个 NULL 互不冲突,天然只约束 external 行) | 同 path 全局最多一条;`ensureExternalEntry` 纯 upsert by path无 "restore trashed" 分支 |
| `size` 字段 | 必填INTEGER NOT NULL | 查询/排序需要external 为最后观测的快照 |
| trash 语义 | `trashedAt` 时间戳;**仅对 internal 有效**external 由 `fe_external_no_trash` CHECK 禁止 trashed | internal 保留软删可逆窗口external 生命周期单向Active → Deleted重建成本为零所以不需要撤销 |
| trash 语义 | `deletedAt` 时间戳;**仅对 internal 有效**external 由 `fe_external_no_delete` CHECK 禁止 trashed | internal 保留软删可逆窗口external 生命周期单向Active → Deleted重建成本为零所以不需要撤销 |
| external 删除语义 | `permanentDelete` 只删 DB 行物理文件不动path-level `ops.remove` 独立提供) | Cherry 不在 entry-level 自动 unlink 用户拥有的文件;用户有需要时走独立的 unmanaged 删除通道 |
| `sourceType` / `role` | 应用层 Zod 验证 + 编译期 checker 注册 | 新增 sourceType 无需 DB migration |
| `file_ref` 防重 | UNIQUE(fileEntryId, sourceType, sourceId, role) | 一个业务对象不会以同一角色重复引用同一文件 |
@@ -116,14 +116,14 @@ export const fileEntryTable = sqliteTable(
/**
* 软删时间戳ms epochnull 表示未 trash。**仅 internal 可用**
* external 恒为 null由 `fe_external_no_trash` CHECK 强制)。
* external 恒为 null由 `fe_external_no_delete` CHECK 强制)。
*/
trashedAt: integer(),
deletedAt: integer(),
...createUpdateTimestamps,
},
(t) => [
index("fe_trashed_at_idx").on(t.trashedAt),
index("fe_deleted_at_idx").on(t.deletedAt),
index("fe_created_at_idx").on(t.createdAt),
// 同 externalPath 全局最多一条。internal 行为 nullSQLite 视多个 NULL
// 互不冲突,因此天然只约束 external 行。兼任查询索引。
@@ -135,8 +135,8 @@ export const fileEntryTable = sqliteTable(
),
// External 不可 trashedtrash/restore 仅对 internalexternal 走 permanentDelete
check(
"fe_external_no_trash",
sql`${t.origin} != 'external' OR ${t.trashedAt} IS NULL`,
"fe_external_no_delete",
sql`${t.origin} != 'external' OR ${t.deletedAt} IS NULL`,
),
],
);
@@ -287,7 +287,7 @@ interface FileInfo {
}
```
**定位**`FileInfo` 是**path 引用下的文件数据形状**——与 `FilePathHandle` 在引用层对应。它不承载身份(无 `id`、无 `origin`、无 `trashedAt`),只承载"磁盘上此刻的一份描述"。
**定位**`FileInfo` 是**path 引用下的文件数据形状**——与 `FilePathHandle` 在引用层对应。它不承载身份(无 `id`、无 `origin`、无 `deletedAt`),只承载"磁盘上此刻的一份描述"。
**与 `FileEntry` 的关系**
@@ -382,7 +382,7 @@ async function ensureExternalEntry(
// 不含 fs.realpathcase-insensitive FS 去重由 Phase 2 视用户反馈补)。
// 是 upsert/查询的唯一 key 来源。
const canonicalPath = canonicalizeExternalPath(params.externalPath);
// External 恒非 trashedfe_external_no_trash CHECK所以不需要 includeTrashed。
// External 恒非 trashedfe_external_no_delete CHECK所以不需要 includeTrashed。
const existing = await fileEntryService.findByExternalPath(canonicalPath);
if (existing) return existing; // name/ext 来自 externalPath不会漂移size 不存
@@ -416,7 +416,7 @@ async function ensureExternalEntry(
### 5.3 trash / restore软删除仅 internal
**纯 DB 操作,不碰 FS。仅对 internal 有效**——external 由 `fe_external_no_trash` CHECK 禁止 trashed调用入口先校验 origin传入 external id 直接抛错schema 层兜底:
**纯 DB 操作,不碰 FS。仅对 internal 有效**——external 由 `fe_external_no_delete` CHECK 禁止 trashed调用入口先校验 origin传入 external id 直接抛错schema 层兜底:
```typescript
async function trash(id: FileEntryId): Promise<void> {
@@ -426,7 +426,7 @@ async function trash(id: FileEntryId): Promise<void> {
`Cannot trash external entry ${id}; external entries have no trashed state. Use permanentDelete.`,
);
}
await fileEntryService.update(id, { trashedAt: Date.now() });
await fileEntryService.update(id, { deletedAt: Date.now() });
}
async function restore(id: FileEntryId): Promise<FileEntry> {
@@ -436,7 +436,7 @@ async function restore(id: FileEntryId): Promise<FileEntry> {
`Cannot restore external entry ${id}; external entries are never trashed.`,
);
}
return fileEntryService.update(id, { trashedAt: null });
return fileEntryService.update(id, { deletedAt: null });
}
```
@@ -700,7 +700,7 @@ export interface FileSchemas {
| `getContentHash` | `FileHandle` | `string` | xxhash-128 |
| `write` | `FileHandle, data` | `FileVersion` | 原子写 |
| `writeIfUnchanged` | `FileHandle, data, version` | `FileVersion` | 乐观并发写 |
| `trash` | `{ id }` | `void` | 软删DB only。**Internal-only** — 传 external id 抛错(`fe_external_no_trash` CHECK |
| `trash` | `{ id }` | `void` | 软删DB only。**Internal-only** — 传 external id 抛错(`fe_external_no_delete` CHECK |
| `restore` | `{ id }` | `FileEntry` | 从 Trash 恢复。**Internal-only** — external 恒非 trashed传 external id 抛错 |
| `permanentDelete` | `FileHandle` | `void` | 删 entry。Internal: unlink FS + 删 DB 行External (managed): **只删 DB 行**物理文件不动Unmanaged path: `ops.remove(path)` 物理删除 |
| `batchTrash` / `batchRestore` | 批量参数 | `BatchOperationResult` | 批量版本internal-only |
@@ -859,7 +859,7 @@ private transformFile(old: DexieFileMetadata): InsertFileEntry {
ext: (old.ext ?? '').replace(/^\./, '') || null,
size: old.size ?? 0,
externalPath: null,
trashedAt: null,
deletedAt: null,
createdAt: new Date(old.created_at).getTime(),
updatedAt: new Date(old.created_at).getTime()
}
@@ -999,7 +999,7 @@ Phase 1a ──→ Phase 1b.1 ──→ Phase 1b.2 ──→ Phase 1b.3 ──
| 类别 | 内容 |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| DB Schema | `src/main/data/db/schemas/file.ts``fileEntryTable` + `fileRefTable`,全部 CHECK 约束(`fe_origin_consistency` / `fe_external_no_trash` / `fe_size_internal_only`)就位 |
| DB Schema | `src/main/data/db/schemas/file.ts``fileEntryTable` + `fileRefTable`,全部 CHECK 约束(`fe_origin_consistency` / `fe_external_no_delete` / `fe_size_internal_only`)就位 |
| DB migration | `pnpm agents:generate` 生成的 SQL |
| 跨进程类型 | `packages/shared/data/types/file/` DTOFileEntry brand DU / FileRef / DanglingState 等);`packages/shared/data/api/schemas/files.ts` DataApi schema 声明 |
| File 类型 | `packages/shared/file/types/ipc.ts` File IPC 契约;`packages/shared/file/types/handle.ts` `FileHandle` tagged union + factory`packages/shared/file/types/info.ts` `FileInfo` + `toFileInfo` **declare only** |
@@ -1077,7 +1077,7 @@ Phase 1a ──→ Phase 1b.1 ──→ Phase 1b.2 ──→ Phase 1b.3 ──
**出口条件**
- renderer 可完整走 FileManager 做增删改
- external entry 的 `trash` 调用被 DB CHECK 阻断(`fe_external_no_trash`
- external entry 的 `trash` 调用被 DB CHECK 阻断(`fe_external_no_delete`
- 写失败时物理文件零残留atomic 保证)
- `writeIfUnchanged` 在同秒+同 size 场景用 content-hash 回退,不误判