Initial Commit
This commit is contained in:
151
openspec/changes/complete-drive/design.md
Normal file
151
openspec/changes/complete-drive/design.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user