Files
suaveplan/openspec/changes/complete-drive/design.md
2026-05-27 23:29:53 +01:00

152 lines
6.4 KiB
Markdown

# Design: complete-drive
Non-normative companion to `proposal.md` + `specs/drive/spec.md`. Architecture, file
layout, algorithms, trade-offs.
## Today's surface (genesis HEAD)
`Drive` + `DriveError` + `DRIVE_CODES` + hydration helpers (`hydrateFrom`,
`getKeyStatus`, `getPresence`, `KEY_GRANT_TOPIC`) + `FileLocationIndex` +
13 exported types. Eleven `DRIVE_*` codes. Middleware for read/write/delete/stat/list.
Streams via positional `signal`. Plugin slot supports install + dispose; no unload.
## Target surface
Same shape, widened consistently:
```ts
class Drive<TStorageMap> {
// unchanged
initialize(): Promise<void>
dispose(): Promise<void>
use<K, S>(id: K, storage: S): Drive<...>
getStorage<K>(id: K): TStorageMap[K]
usePlugin<T>(plugin: DrivePlugin<T>): Promise<T>
useMiddleware(mw: DriveMiddleware): void
// widened
read(path: string, opts?: ReadOptions): Promise<Uint8Array>
write(path: string, data: Uint8Array, opts?: WriteOptions): Promise<void>
delete(path: string, opts?: DeleteOptions): Promise<boolean>
stat(path: string, opts?: StatOptions): Promise<DriveStats | null>
list(prefix?: string, opts?: ListOptions): Promise<DriveListPage>
createReadStream(path: string, opts?: ReadStreamOptions): ReadableStream<Uint8Array>
createWriteStream(path: string, opts?: WriteStreamOptions): WritableStream<Uint8Array>
// new
copy(src: string, dst: string, opts?: CopyOptions): Promise<void>
move(src: string, dst: string, opts?: MoveOptions): Promise<void>
scan(prefix?: string, opts?: ScanOptions): AsyncIterable<DriveEntry>
watch(path: string, opts?: WatchOptions): DriveWatchHandle
usage(): Promise<DriveUsage>
setQuota(quota: DriveQuota): void
// plugin lifecycle
unloadPlugin(name: string): Promise<void>
listPlugins(): readonly DrivePluginInfo[]
findPlugin(name: string): DrivePluginInfo | undefined
// sharing seam
share(path: string, audience: ShareAudience): Promise<ShareHandle>
revoke(handle: ShareHandle): Promise<void>
listShares(path?: string): Promise<readonly ShareHandle[]>
// unchanged hydration
hydrateFrom(t: DriveTemplate, opts?: HydrateOptions): Promise<HydrateResult>
getKeyStatus(path: string): KeyStatus
getPresence(path: string): ChunkPresence
}
```
## File layout (delta)
```text
src/
drive/ (unchanged: drive.ts + hydration helpers)
error-codes.ts (+6 codes)
file-operations/
read.ts | write.ts | delete.ts | stat.ts | list.ts | streams.ts (existing, signatures widened)
copy.ts (NEW)
move.ts (NEW)
watch.ts (NEW)
scan.ts (NEW — auto-pagination helper)
observability/
usage.ts (NEW)
quota.ts (NEW)
spans.ts (NEW — `withSpan('drive.<op>', attrs, fn)`)
plugins/
registry.ts (+unload/list/find, name-keyed map)
types/
drive.types.ts (new interfaces: DeleteOptions, StatOptions, ReadStreamOptions,
WriteStreamOptions, CopyOptions, MoveOptions, ScanOptions,
WatchOptions, DriveWatchHandle, DriveChangeEvent,
DriveListPage, DriveUsage, DriveQuota, DrivePluginInfo,
DriveShareLike, ShareAudience, ShareHandle)
```
All new files ship with sibling `.md` + co-located `.test.ts`. Each feature gets a
subpath export in `package.json` per AGENTS §6.
## Algorithms
### `copy(src, dst)`
1. `ensureReady()`; validate both paths.
2. Resolve src + dst storages (may differ).
3. If both storages are the same and `storage.copy?` exists → call it.
4. Otherwise stream src → dst via `createReadStream(src).pipeTo(createWriteStream(dst))`.
5. Wrap in `runMiddleware("copy", ...)`; on any throw, surface as `DriveError(DRIVE.COPY_FAILED, { cause })`.
6. Emit `drive:copy:after` with `{ src, dst, bytes }` on the bus.
### `move(src, dst)`
1. If `storage.move?` and same storage → call it (atomic rename).
2. Else: `copy(src, dst)`; on success `delete(src)`; on delete failure attempt rollback `delete(dst)` and throw `DriveError(DRIVE.MOVE_FAILED)`.
### `watch(path)`
`EventBusLike.subscribe("drive:change", filter: e => e.path === path || e.path.startsWith(path + "/"))`.
Returns `{ events: ReadableStream → AsyncIterable, unsubscribe }`. When no bus is
injected, drive falls back to a private `LightweightFanout` (≤256 listeners) so the
API remains usable in zero-config tests.
### `usage()`
- Per storage: prefer native `storage.usage?()`.
- Fallback: stream `scan()` and sum `stat.size` with backpressure via `AbortSignal` and 5s cache.
- Returns `{ byStorage: Record<string, { bytes, files }>; total: { bytes, files } }`.
### Quota enforcement
- Soft cap → emit `drive:quota:warning` (one-shot per cap crossing; reset on usage dropping >5%).
- Hard cap → throw `DriveError(DRIVE.QUOTA_EXCEEDED)` from `write`/`copy`/`move` before the underlying storage call.
### Sharing seam
`DriveShareLike` lives in drive (not drive-sharing), so drive owns the contract and
`drive-sharing` adapts to it — exactly the inversion required to keep drive's
dependency footprint sharing-free.
## Trade-offs
- **Why options bundle over keeping positional `signal?`** — every other op already
uses an options bundle. Consistent surface > one-cycle source churn. Overload
preserves source compatibility.
- **Why ship `scan` as well as `list`** — `list` is a single page (mirrors storage
capability). `scan` is the consumer-friendly auto-paginating iterable. Both are
cheap to maintain.
- **Why not pull in `drive-sharing` as a peer** — keeps drive a true leaf in the
sharing graph; downstream apps that don't share never load the share code.
- **Why expose `DriveShareLike` from drive, not from `@suaveplan/types`** — drive
owns the *semantics* of paths and audiences; types should remain pure data.
When `complete-types-drive-promotion` lands, only the *data* portions (`ShareHandle`,
`ShareAudience`) move; the interface stays here.
## Cross-package contract notes
- The middleware contract is **observed**, not enforced — adding `copy`/`move`/`watch`
slots is purely additive; existing plugins that only implement `read`/`write` keep
working unchanged.
- `EventBusLike` is the only DI surface required by `watch` / quota / hydrate
progress. Plugins that want change events subscribe through the same bus.