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

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)

  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.