mirror of
https://github.com/moeru-ai/airi.git
synced 2026-10-31 03:44:38 +08:00
## Summary
Imported Live2D ZIP and VRM models live only in the browser IndexedDB,
so they do not reach other devices. This PR adds private per-account
model sync: model metadata in Postgres, original files in S3, and device
caches in IndexedDB.
Cloud sync is behind the new local `display-model-sync` feature flag. It
is off by default.
Object storage is now required. The API does not start without
`S3_BUCKET` and `S3_REGION`. **Set them in every deployed environment
before merge.** The local compose stack provides RustFS.
The upload logic is generic (`upload_sessions`, `UploadSessionService`,
`TransferQueue`), so other file types can reuse it. The ADR is
`server/docs/ai/adr/2026-10-08-display-model-cloud-sync.md`. Its status
is still `proposed`. It records that a local file is a device working
copy and an uploaded file is an account asset.
This PR is independent.
## Change map
| Module | Before | After | Description |
| --- | --- | --- | --- |
| `object-store` adapter | Signs PUT with content type and metadata |
Also signs `x-amz-checksum-sha256` and `If-None-Match: *`. Reads the
stored checksum and supports ranged GET | The store rejects wrong bytes
and second writes |
| `upload-sessions` (new) | None | Server-chosen key, idempotent
reservation, size, checksum and signature check | Generic. The business
domain owns meaning and quota |
| `display-models` domain and routes (new) | None | List, reserve,
complete, download, rename, delete under `/api/v1/display-models` |
Revision checks. Delete writes a tombstone |
| Drizzle `0033` | None | `display_models` and `upload_sessions` | |
| `libs/file-transfer` (new) | None | Hash, signed PUT, verified
download, durable account-scoped queue | Resumes after restart. Drops
results after account change |
| Device storage for models | `localforage` in the store |
`database/repos/display-models.repo.ts`, still on `localforage` with the
same keys | No local data moves. The `unstorage` move is a separate pull
request |
| S3 configuration | Optional | Required at startup | Same class of
dependency as Redis |
| Local compose stack | No object storage | RustFS, a bucket, and CORS
for local web origins | `s3.localhost` resolves inside the network and
in a browser |
| `stores/display-models` | Local files only | Cloud directory, sync
status, upload queue, download on demand | Gated by the feature flag |
| `stores/settings/stage-model` | Missing model falls back to default |
A failed cloud download keeps the selection | Local-missing models still
fall back |
| Model selector | Local list | Cloud-only entries, status, upload and
delete actions | |
## Architecture and behavior
```mermaid
sequenceDiagram
participant A as Device A
participant API as API
participant DB as Postgres
participant S3 as Private S3
participant B as Device B
A->>A: Import and save File locally
A->>API: POST /uploads (id, requestId, size, sha256)
API->>DB: Lock owner, check quota, insert session
API-->>A: Signed PUT (checksum, write-once)
A->>S3: PUT bytes
A->>API: POST /uploads/:id/complete
API->>S3: HEAD checksum and size, ranged GET signature
alt mismatch
API->>S3: Delete object, abort session (422)
else match
API->>DB: Publish ready model
end
B->>API: GET /display-models
B->>API: POST /:id/download
B->>S3: GET, verify size and sha256, cache locally
```
## Boundaries and risks
| Invariant or boundary | Failure mode | Protection | Evidence or gap |
| --- | --- | --- | --- |
| Only verified bytes become ready | Client uploads other bytes |
Store-validated SHA-256, size and signature check | Unit tests with a
fake store |
| Ready bytes never change | Reused signed URL overwrites |
`If-None-Match: *` and a per-upload key | Integration test passes
against RustFS: wrong bytes get 400, a second write gets 412. Production
provider unverified |
| Quota holds under races | Parallel reservations exceed it | Advisory
lock per owner in one transaction | Sequential test only. No concurrent
test |
| Accounts stay isolated | Another account reads a model | Every query
filters by owner | Tests for upload, download and list |
| Account switch is safe | Stale upload result lands in new account |
Queue generation check | Queue unit test |
| Deleted models leave devices | Cache stays | Tombstone removes the
cache on next sync | Not tested |
Not in this PR: physical S3 cleanup after delete, Live2D package
validation, card reference check on delete, stage-pocket acceptance.
## Visual changes
The model selector gains sync status and menu items. They show only when
the flag is on, the user is signed in, and the API has S3. I could not
capture before and after images without that backend. This is
unverified.
## Verification
- `display-models.repo.browser.test.ts` runs in CI Chromium. It reads a
4 MiB record in the previous release format, and keeps a model and an
upload across repository instances.
Run after the rebase onto `main`:
- `tsc --noEmit` in `server/apps/api`: no errors.
- `vitest run server/apps/api`: 78 files pass, 2 skipped.
- `vue-tsc --noEmit` in `stage-ui`: no errors.
- `vitest run --project node` for the model repository, model store,
`settings`, `file-transfer`, and feature flags in `stage-ui`: 23 tests
pass.
- `eslint` on changed files: no errors.
Run before the rebase, with no later change to that code:
- `object-store.integration.test.ts` against local RustFS
(`TEST_S3_ENDPOINT`): 2 tests pass.
- CORS preflight from `http://localhost:5173` for a signed PUT: allowed.
Not run:
- `pnpm lint` and the root `pnpm typecheck`.
- `vue-tsc` for `stage-web` and `stage-tamagotchi` after the storage
change.
- The full compose stack. Only RustFS was started.
- Real production S3, browser or Electron end-to-end, and the two-device
flow.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>