152 lines
6.4 KiB
Markdown
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.
|