6.4 KiB
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:
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)
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)
ensureReady(); validate both paths.- Resolve src + dst storages (may differ).
- If both storages are the same and
storage.copy?exists → call it. - Otherwise stream src → dst via
createReadStream(src).pipeTo(createWriteStream(dst)). - Wrap in
runMiddleware("copy", ...); on any throw, surface asDriveError(DRIVE.COPY_FAILED, { cause }). - Emit
drive:copy:afterwith{ src, dst, bytes }on the bus.
move(src, dst)
- If
storage.move?and same storage → call it (atomic rename). - Else:
copy(src, dst); on successdelete(src); on delete failure attempt rollbackdelete(dst)and throwDriveError(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 sumstat.sizewith backpressure viaAbortSignaland 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)fromwrite/copy/movebefore 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
scanas well aslist—listis a single page (mirrors storage capability).scanis the consumer-friendly auto-paginating iterable. Both are cheap to maintain. - Why not pull in
drive-sharingas a peer — keeps drive a true leaf in the sharing graph; downstream apps that don't share never load the share code. - Why expose
DriveShareLikefrom drive, not from@suaveplan/types— drive owns the semantics of paths and audiences; types should remain pure data. Whencomplete-types-drive-promotionlands, 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/watchslots is purely additive; existing plugins that only implementread/writekeep working unchanged. EventBusLikeis the only DI surface required bywatch/ quota / hydrate progress. Plugins that want change events subscribe through the same bus.