Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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.

View File

@@ -0,0 +1,112 @@
# Proposal: complete-drive — fill VFS-core gaps in @suaveplan/drive
- **Change ID**: `complete-drive`
- **Capability**: `drive`
- **Package**: `@suaveplan/drive`
- **Status**: PROPOSED
- **Design**: ./design.md
- **Spec delta**: ./specs/drive/spec.md
- **Companions** (parallel, share surface, scope-disjoint): `complete-drive-uri`, `complete-types-drive-promotion`
## Why
The current genesis `@suaveplan/drive` ships the VFS spine (constructor overloads,
init/dispose FSM, plugin slot, middleware chain for read/write/delete/stat/list,
template hydration via `hydrateFrom`/`getKeyStatus`/`getPresence`, `DRIVE_*` codes)
but several capabilities implied by the package's identity as a **file-VFS core** are
either partial or missing. They block downstream packages (`drive-mesh`,
`drive-sharing`, `drive-react-sharing`, `drive-plugin-cache`) from composing against
a stable surface.
## What
### Audit findings (genesis vs. upstream + CLAUDE.md identity)
| # | Gap | Today | Required |
|---|---|---|---|
| 1 | `createReadStream` signature | `(path, signal?)` positional | overload `(path, options?: { offset?; length?; signal? })` so range reads and aborts cohabit |
| 2 | `createWriteStream` signature | `(path, signal?)` positional | overload `(path, options?: { signal? })` for parity, no behaviour drift |
| 3 | `Drive.copy(src, dst, opts?)` | missing | atomic copy via middleware chain, emits `drive:copy:before/after` |
| 4 | `Drive.move(src, dst, opts?)` | missing | rename = copy+delete on storages without native rename; middleware-wrapped |
| 5 | Op-set width for `delete`/`stat` | `signal?` positional | accept `{ signal? }` options bundle (matches `read`/`write`) |
| 6 | `Drive.list` return type | typed `unknown` | typed `AsyncIterable<DriveEntry>` + page result; respect `recursive`/`limit`/`cursor` |
| 7 | `Drive.watch(path, opts?)` | missing | EventBus-backed change subscription returning `AsyncIterable<DriveChangeEvent>` + `unsubscribe()` |
| 8 | Hydration progress events | callback only | also emit `drive:hydrate:progress` on the EventBus (delivery to multiple subscribers) |
| 9 | Middleware coverage | read/write/delete/stat/list | add `copy`, `move`, `watch` middleware slots so plugins can wrap every op |
| 10 | Plugin lifecycle API | install + dispose-on-drive-dispose | `unloadPlugin(name)`, `listPlugins()`, `findPlugin(name)`, ordered handle map, idempotent unload |
| 11 | Quota / usage | absent | `Drive.usage()` aggregating per-storage byte counts; `drive:quota:exceeded` event when caller-configured soft/hard caps trip |
| 12 | Sharing integration hook | absent | `DriveShareLike` interface in drive (no drive-sharing dep) + `drive.shareHook?: DriveShareLike` slot |
| 13 | `DRIVE_CODES` registry | 11 codes | add `COPY_FAILED`, `MOVE_FAILED`, `WATCH_FAILED`, `QUOTA_EXCEEDED`, `OPERATION_ABORTED`, `STORAGE_READONLY` |
| 14 | Snapshot/transaction API | upstream has `cow`, `journal`, `snapshots` siblings | **OUT OF SCOPE** — handled by future `drive-snapshot` / `drive-journal` plugin packages; we land only the middleware seam they will plug into |
| 15 | OTEL spans on every op | partial | spans per public entrypoint (`drive.read`, `drive.write`, `drive.copy`, …) named `drive.<op>` with `drive.storage.id` attribute |
| 16 | 100% coverage | currently below 100% | hard gate at 100/100/100/100 (statements/branches/functions/lines), istanbul |
### Out of scope
- **Sharing-specific UX** — owned by `@suaveplan/drive-sharing` and
`@suaveplan/drive-react-sharing`. We expose only the `DriveShareLike` interface
(no runtime dep on either package).
- **Mesh transport details** — owned by `@suaveplan/drive-mesh-protocol`,
`@suaveplan/drive-mesh`, `@suaveplan/drive-transport-https`,
`@suaveplan/drive-transport-webrtc`, `@suaveplan/drive-webrtc`. We expose only
the plugin slot + middleware seam they consume.
- **Template signing / nonce verification** — owned by `@suaveplan/drive-template`.
We continue to invoke its pipeline from `hydrateFrom`; no signing logic moves into
drive itself.
- **URI / locator** — owned by `@suaveplan/drive-uri` (covered by sibling change
`complete-drive-uri`).
- **Shared cross-package types** — promoted into `@suaveplan/types` by sibling
change `complete-types-drive-promotion`; this change imports them once they land
but does not block on the promotion (the local types stay valid until then).
- **Encryption / chunking / dedup / journal / extents / snapshots / transforms /
metadata-store** — every one of these is a sibling package today
(`drive-plugin-chunks`, future `drive-plugin-*`); not a drive-core responsibility.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/drive` to enumerate. For each dependent:
- `@suaveplan/drive-template` — [x] tests run / [x] docs reviewed / [ ] no action needed — consumes `hydrateFrom` + nonce LRU; new progress events are additive, no breakage.
- `@suaveplan/drive-plugin-chunks` — [x] tests run / [x] docs reviewed / [ ] no action needed — plugin/middleware contract widens (additive); existing plugin keeps working.
- `@suaveplan/drive-plugin-cache` — [x] tests run / [x] docs reviewed / [ ] no action needed — new `copy`/`move` middleware slots are opt-in.
- `@suaveplan/drive-mesh-protocol` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: pure wire-protocol, does not touch Drive surface).
- `@suaveplan/drive-mesh` — [x] tests run / [x] docs reviewed / [ ] no action needed — adopts widened plugin lifecycle (`unloadPlugin`).
- `@suaveplan/drive-transport-https` — [x] tests run / [x] docs reviewed / [ ] no action needed — passes through middleware, no API impact.
- `@suaveplan/drive-transport-webrtc` — [x] tests run / [x] docs reviewed / [ ] no action needed.
- `@suaveplan/drive-webrtc` — [x] tests run / [x] docs reviewed / [ ] no action needed.
- `@suaveplan/drive-sharing` — [x] tests run / [x] docs reviewed / [ ] no action needed — gains optional `DriveShareLike` integration; legacy path unchanged.
- `@suaveplan/drive-react-sharing` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: re-exports drive-sharing types only).
## Migration
- **Old**: `drive.createReadStream(path, signal)` / `createWriteStream(path, signal)` — positional `signal`.
- **New**: `drive.createReadStream(path, { offset?, length?, signal? })` / `createWriteStream(path, { signal? })` — options bundle.
- **Compatibility**: both forms accepted via overload; positional-`signal` form remains valid for one minor cycle, then becomes a TypeScript error in `0.1.0`.
- **Search command for consumers**: `grep -rn "createReadStream\|createWriteStream" packages/ | grep -v "/dist/\|/coverage/"`
- **Recommended replacement**: always pass the options bundle.
- **Old**: `drive.delete(path, signal)` / `drive.stat(path, signal)` positional.
- **New**: `drive.delete(path, { signal? })` / `drive.stat(path, { signal? })` options bundle (positional kept as overload for one cycle).
- **Search**: `grep -rn "drive\.\(delete\|stat\)(" packages/ | grep -v "/dist/"`.
## Parity Checklist
| Capability | Upstream (`main`) | Genesis after this change | Notes |
|---|---|---|---|
| POSIX `open/close/read/write/lseek/fsync` file-descriptor model | ✅ | 🚫 | Scoped out: genesis Drive is a high-level VFS, not POSIX FD emulation. The upstream FD API is unused in any genesis dependent and superseded by `read`/`write` + streams. |
| `readFile`/`writeFile`/`appendFile` | ✅ | ⬆️ | Renamed to `read`/`write`; `append` via streams. |
| `copyFile`/`rename`/`truncate` | ✅ | ⬆️ | Becomes `copy`/`move`; `truncate` via streams or future plugin. |
| `mkdir`/`rmdir`/`rm`/`readdir`/`readdirWithTypes` | ✅ | 🚫 | Scoped out: genesis storage substrate is key-value (no real directories). `list(prefix)` covers the use case; directories are a sibling-plugin concern. |
| `stat`/`lstat`/`access`/`exists`/`chmod`/`chown`/`utimes` | ✅ | ⬆️ | `stat` with options bundle stays; permissions/ownership are sibling-plugin concerns (no POSIX permission model in genesis universal tier). |
| `symlink`/`readlink`/`realpath` | ✅ | 🚫 | Scoped out: symlinks belong to a future `drive-plugin-symlinks` if needed. |
| `cd`/`pwd` cwd model | ✅ | 🚫 | Scoped out: genesis Drive forbids relative paths (path validator rejects `..` and leading `/`); cwd has no meaning. |
| `createReadStream`/`createWriteStream` | ✅ | ⬆️ | Options-bundle overload; range reads via `{ offset, length }`. |
| Plugin manager (add/remove/list) | ✅ | ⬆️ | `usePlugin` + new `unloadPlugin`/`listPlugins`/`findPlugin`. |
| Storage failover | ✅ | 🚫 | Scoped out: failover belongs to `drive-mesh` server-fallback path or a future `drive-plugin-failover`. |
| `chunks` / `cow` / `dedup` / `encryption` / `extents` / `journal` / `metadata-store` / `snapshots` / `transforms` subsystems | ✅ | 🚫 | Each is or will be a sibling package (`drive-plugin-chunks` already ships). VFS core only exposes the plugin/middleware seam. |
| Template hydration (`hydrateFrom`/`getKeyStatus`/`getPresence`) | 🚫 | ⬆️ | Genesis-original capability, added by `drive-template`. |
| EventBus integration | ✅ (direct emitter) | ⬆️ | Genesis uses injectable `EventBusLike` from `@suaveplan/types`; same contract, no upstream coupling. |
| Quota / usage | 🚫 | ⬆️ | New: aggregates per-storage usage + emits soft/hard threshold events. |
| Sharing-integration hook | 🚫 | ⬆️ | New: `DriveShareLike` interface (drive owns the contract; `drive-sharing` implements). |

View File

@@ -0,0 +1,215 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: drive (drive)
**Package**: `@suaveplan/drive`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Options-bundle parity across every public op
`Drive.delete`, `Drive.stat`, `Drive.createReadStream`, and `Drive.createWriteStream` MUST accept an options bundle (`{ signal? }` plus, for streams, `{ offset?, length? }`). The legacy positional `signal?` form MUST remain accepted via overload for one minor cycle and MUST be observably equivalent.
#### Scenario: delete accepts options bundle
- **WHEN** `drive.delete(path, { signal })` is called with an already-aborted signal
- **THEN** the call MUST reject with `DriveError(DRIVE.OPERATION_ABORTED)` and MUST NOT touch the underlying storage
#### Scenario: createReadStream honours offset and length
- **WHEN** `drive.createReadStream(path, { offset: 4, length: 8 })` is called on a 16-byte file
- **THEN** the resulting stream MUST emit exactly bytes `[4..12)` of the source
**Verified by:** `src/file-operations/delete.test.ts::"delete aborts on pre-aborted signal"` AND `src/file-operations/streams.test.ts::"createReadStream honours offset and length"`
---
### Requirement: `Drive.copy(src, dst, opts?)` is middleware-wrapped and atomic-on-error
`Drive.copy` SHALL invoke the `copy` middleware chain in registration order, prefer `storage.copy?` when source and destination share a storage, otherwise fall back to streaming `src → dst`. Any underlying failure MUST surface as `DriveError(DRIVE.COPY_FAILED, { cause })` and MUST NOT leave a partial file at `dst` (the destination is deleted on write failure).
#### Scenario: copy emits before/after middleware
- **WHEN** a middleware with a `copy(ctx, next)` hook is registered and `drive.copy("a","b")` succeeds
- **THEN** the hook MUST run exactly once outer-first on the request path and outer-last on the response path
#### Scenario: copy cleans up on write failure
- **WHEN** the destination storage's underlying write throws midway
- **THEN** `drive.copy` MUST throw `DriveError(DRIVE.COPY_FAILED)` AND `await drive.stat("b") === null`
**Verified by:** `src/file-operations/copy.test.ts::"copy invokes middleware outer-first/outer-last"` AND `src/file-operations/copy.test.ts::"copy rolls back partial destination on write failure"`
---
### Requirement: `Drive.move(src, dst, opts?)` is rename-or-copy-then-delete with rollback
`Drive.move` SHALL prefer `storage.move?` when src/dst share a storage; otherwise it SHALL `copy(src,dst)` then `delete(src)`. A failure during the post-copy delete MUST attempt `delete(dst)` rollback and surface as `DriveError(DRIVE.MOVE_FAILED, { cause })`.
#### Scenario: cross-storage move is copy+delete
- **WHEN** `drive.move("a","b")` is called with src and dst on different storages
- **THEN** after success `await drive.stat("a") === null` AND `await drive.read("b")` returns the original bytes
#### Scenario: rollback on delete failure
- **WHEN** the source delete throws after a successful copy
- **THEN** `drive.move` MUST throw `DriveError(DRIVE.MOVE_FAILED)` AND `await drive.stat("b") === null`
**Verified by:** `src/file-operations/move.test.ts::"cross-storage move copies then deletes"` AND `src/file-operations/move.test.ts::"move rolls back destination on delete failure"`
---
### Requirement: `Drive.list` returns a typed page and `Drive.scan` auto-paginates
`Drive.list(prefix?, opts?)` MUST return `Promise<DriveListPage>` where `DriveListPage = { entries: DriveEntry[]; cursor?: string }`. `Drive.scan(prefix?, opts?)` MUST return an `AsyncIterable<DriveEntry>` that auto-pages through `list` until exhausted and MUST honour `signal` between pages.
#### Scenario: scan yields every entry across pages
- **WHEN** the underlying storage returns 250 entries across 3 pages
- **THEN** `for await (const e of drive.scan())` yields exactly 250 entries in storage order
**Verified by:** `src/file-operations/scan.test.ts::"scan yields every entry across pages"` AND `src/file-operations/list.test.ts::"list returns typed DriveListPage"`
---
### Requirement: `Drive.watch(path, opts?)` delivers change events via the injected EventBus
`Drive.watch` SHALL return `{ events: AsyncIterable<DriveChangeEvent>; unsubscribe(): void }`. Events MUST fire for `write`, `delete`, `copy`, and `move` whose path equals or is a descendant of the subscribed path. When no `EventBusLike` is injected, drive SHALL use a private in-process fan-out so the API remains usable in zero-config mode. Subscription errors MUST surface as `DriveError(DRIVE.WATCH_FAILED, { cause })`.
#### Scenario: watch fires for descendant writes
- **WHEN** `drive.watch("folder")` is active and `drive.write("folder/x", bytes)` resolves
- **THEN** the next `DriveChangeEvent` yielded MUST have `{ kind: "write", path: "folder/x" }`
#### Scenario: unsubscribe stops delivery
- **WHEN** `unsubscribe()` is called
- **THEN** subsequent writes MUST NOT yield further events on that iterator
**Verified by:** `src/file-operations/watch.test.ts::"watch fires for descendant writes"` AND `src/file-operations/watch.test.ts::"unsubscribe stops delivery"`
---
### Requirement: Hydration progress is also published on the EventBus
`Drive.hydrateFrom` SHALL publish `drive:hydrate:progress`, `drive:hydrate:complete`, and `drive:hydrate:aborted` on the injected `EventBusLike` (in addition to invoking `options.onProgress` when supplied). Topic payloads MUST match the existing `HydrateProgressEvent` shape.
#### Scenario: progress reaches a bus subscriber
- **WHEN** a subscriber attaches to `drive:hydrate:progress` before `drive.hydrateFrom(template)` is called
- **THEN** the subscriber MUST receive at least one event with `stage: "indexing"` and one with `stage: "complete"`
**Verified by:** `src/drive/hydrate-from.test.ts::"hydrate progress reaches EventBus subscriber"`
---
### Requirement: Middleware widening — copy/move/watch slots
`DriveMiddleware` MUST accept optional `copy`, `move`, and `watch` hooks, each invoked through `PluginRegistry.runMiddleware` with the same outer-first/outer-last ordering as `read`/`write`/`delete`/`stat`/`list`. Plugins authored before this change (no new hooks) MUST continue to function unchanged.
#### Scenario: legacy plugin keeps working
- **WHEN** a `DriveMiddleware` implementing only `read` is registered
- **THEN** `drive.copy("a","b")` MUST succeed without invoking the legacy plugin
**Verified by:** `src/plugins/registry.spec-conformance.test.ts::"copy/move/watch middleware run outer-first/outer-last"` AND `src/plugins/registry.spec-conformance.test.ts::"legacy read-only plugin keeps working after middleware widening"`
---
### Requirement: Plugin lifecycle — unload, list, find
`Drive.unloadPlugin(name)` MUST be idempotent: calling it twice MUST NOT throw, and a successful unload MUST invoke the plugin's `dispose?()`. `Drive.listPlugins()` MUST return plugins in registration order. `Drive.findPlugin(name)` MUST return `undefined` for unknown names. Unloading an unknown name MUST throw `DriveError(DRIVE.PLUGIN_FAILED, "unknown plugin: <name>")`.
#### Scenario: unload disposes once
- **WHEN** `drive.usePlugin(p)` then `drive.unloadPlugin(p.name)` is called twice
- **THEN** `p.dispose()` MUST have been called exactly once
**Verified by:** `src/plugins/registry.test.ts::"unloadPlugin disposes exactly once across repeated calls"` AND `src/plugins/registry.test.ts::"listPlugins returns registration order"`
---
### Requirement: `Drive.usage()` aggregates per-storage and is 5s-cached
`Drive.usage()` MUST return `{ byStorage: Record<string, DriveUsageEntry>; total: DriveUsageEntry }` where `DriveUsageEntry = { bytes: number; files: number }`. It MUST prefer `storage.usage?()` when supplied; otherwise it MUST sum `stat.size` over `scan()`. Successive calls within 5 seconds MUST be served from cache. The cache MUST be invalidated by any `write`/`delete`/`copy`/`move`.
#### Scenario: cache is invalidated by writes
- **WHEN** `drive.usage()` is called, then `drive.write("a", bytes)`, then `drive.usage()`
- **THEN** the second result's `total.bytes` MUST be strictly greater than the first
**Verified by:** `src/observability/usage.test.ts::"usage cache is invalidated by writes"` AND `src/observability/usage.test.ts::"usage prefers native storage.usage when available"`
---
### Requirement: `Drive.setQuota` enforces soft/hard limits
`Drive.setQuota({ soft?, hard? })` MUST cause every subsequent `write`/`copy`/`move` to first re-check `usage()`. Crossing `soft` MUST emit `drive:quota:warning` exactly once per crossing. Exceeding `hard` MUST throw `DriveError(DRIVE.QUOTA_EXCEEDED)` BEFORE the underlying storage write.
#### Scenario: hard cap blocks the write
- **WHEN** quota `{ hard: 100 }` is set and a 200-byte write is issued against an empty drive
- **THEN** the write MUST throw `DriveError(DRIVE.QUOTA_EXCEEDED)` AND `await drive.stat(path) === null`
**Verified by:** `src/observability/quota.test.ts::"hard cap blocks write before storage call"` AND `src/observability/quota.test.ts::"soft cap emits drive:quota:warning once per crossing"`
---
### Requirement: Sharing-integration hook — `DriveShareLike` seam
`DriveOptions.shareHook?: DriveShareLike` MUST be accepted. `Drive.share`/`Drive.revoke`/`Drive.listShares` MUST delegate to the hook. When no hook is registered, each of these MUST throw `DriveError(DRIVE.PLUGIN_FAILED, "no share hook")`. Drive MUST NOT import `@suaveplan/drive-sharing` at runtime.
#### Scenario: missing hook throws PLUGIN_FAILED
- **WHEN** `drive.share("a", audience)` is called without a registered hook
- **THEN** the call MUST throw `DriveError(DRIVE.PLUGIN_FAILED)` with message containing `"no share hook"`
#### Scenario: hook receives the call
- **WHEN** a hook is registered and `drive.share("a", audience)` is called
- **THEN** the hook's `share` MUST receive arguments `("a", audience)` exactly
**Verified by:** `src/drive/drive.test.ts::"share throws PLUGIN_FAILED without hook"` AND `src/drive/drive.test.ts::"share delegates to registered DriveShareLike hook"`
---
### Requirement: Error-code registry widening
`DRIVE_CODES` MUST be extended with `COPY_FAILED`, `MOVE_FAILED`, `WATCH_FAILED`, `QUOTA_EXCEEDED`, `OPERATION_ABORTED`, `STORAGE_READONLY`. The registry MUST remain frozen and every new value MUST start with `DRIVE.`. Each new code MUST be raised by at least one call site in `src/`.
#### Scenario: every new code is reachable
- **WHEN** the conformance test iterates `DRIVE_CODES` filtered to the six new entries
- **THEN** each MUST appear in a `grep -r "DRIVE_CODES\.<NAME>" src/` result with at least one `throw new DriveError` call site
**Verified by:** `src/error-codes.spec-conformance.test.ts::"every new DRIVE_* code is thrown from at least one call site"` AND `src/error-codes.spec-conformance.test.ts::"DRIVE_CODES remains frozen with DRIVE. prefix after widening"`
---
### Requirement: OpenTelemetry spans on every public op
Every public operation (`read`, `write`, `delete`, `stat`, `list`, `scan`, `copy`, `move`, `watch`, `hydrateFrom`, `usage`) MUST be wrapped in a span named `drive.<op>` with attributes `drive.path` (where applicable), `drive.storage.id`, and `drive.bytes` (where applicable). The span MUST end with `SpanStatusCode.ERROR` and record the exception when the op throws. With no exporter registered, span overhead MUST be no-op.
#### Scenario: read span carries storage id
- **WHEN** an in-memory exporter is registered and `drive.read("a")` succeeds
- **THEN** exactly one span named `drive.read` MUST be recorded with attribute `drive.storage.id === "default"`
**Verified by:** `src/observability/spans.test.ts::"drive.read span carries drive.storage.id attribute"` AND `src/observability/spans.test.ts::"thrown ops mark span ERROR and record exception"`
---
### Requirement: 100% coverage gate
`bunx turbo test:coverage --filter=@suaveplan/drive` MUST report 100% statements, 100% branches, 100% functions, 100% lines (istanbul). No `c8 ignore`, no `it.skip`, no tautological assertion (CLAUDE.md §2b). Coverage MUST be enforced by `vitest.config.ts` `thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 }`.
#### Scenario: coverage gate fails closed
- **WHEN** a deliberate uncovered branch is introduced
- **THEN** `bunx turbo test:coverage --filter=@suaveplan/drive` MUST exit non-zero
**Verified by:** `vitest.config.ts::thresholds-100` AND `src/coverage-gate.test.ts::"vitest threshold is 100/100/100/100"`

View File

@@ -0,0 +1,94 @@
# Tasks: complete-drive
> All tasks land in `packages/drive/drive/`. Follow CLAUDE.md `### Quality` and
> `openspec/AGENTS.md` §13 pre-ship gates. Coverage gate is 100/100/100/100.
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Audit & spec lock-in
- [ ] 1.1 Snapshot current public surface — `bunx turbo build --filter=@suaveplan/drive` then grep `dist/index.d.ts` for the exported symbol list; paste into design.md `## Today's surface`.
- [ ] 1.2 Confirm sibling-change scope boundaries with `complete-drive-uri` (URI/locator owner) and `complete-types-drive-promotion` (cross-package types owner) — no duplicate requirements.
## 2. Error-code registry
- [ ] 2.1 Extend `src/error-codes.ts` `DRIVE_CODES` with `COPY_FAILED`, `MOVE_FAILED`, `WATCH_FAILED`, `QUOTA_EXCEEDED`, `OPERATION_ABORTED`, `STORAGE_READONLY` (keeps `defineCodes("DRIVE", [...])` frozen).
- [ ] 2.2 Update `src/error-codes.md` and the conformance test `src/error-codes.spec-conformance.test.ts` to assert the wider set.
## 3. Op surface widening
- [ ] 3.1 Add `DeleteOptions`, `StatOptions`, `ReadStreamOptions`, `WriteStreamOptions` to `src/types/drive.types.ts`; export them from `src/index.ts`.
- [ ] 3.2 Add overload signatures on `Drive.delete`/`Drive.stat`/`Drive.createReadStream`/`Drive.createWriteStream` accepting the options bundle (positional `signal?` kept as a second overload for one minor cycle).
- [ ] 3.3 Implement range-read in `src/file-operations/streams.ts` using `storage.createReadStream({ offset, length })` when `capabilities.rangeReads === true`, else slice in the default fallback.
## 4. Copy / move
- [ ] 4.1 New module `src/file-operations/copy.ts` + `.md`: middleware-wrapped copy; uses `storage.copy?` when available, else read-then-write; emits `drive:copy:before`/`drive:copy:after`. Errors surface as `DriveError(DRIVE.COPY_FAILED, { cause })`.
- [ ] 4.2 New module `src/file-operations/move.ts` + `.md`: same shape; uses `storage.move?` (rename), else copy + delete with rollback on failure; emits `drive:move:before`/`drive:move:after`. Errors surface as `DriveError(DRIVE.MOVE_FAILED, { cause })`.
- [ ] 4.3 Wire `Drive.copy(src, dst, opts?)` and `Drive.move(src, dst, opts?)` through `PluginRegistry.runMiddleware`. Add `copy?`/`move?` slots to `DriveMiddleware`.
## 5. List typing
- [ ] 5.1 Replace `Promise<unknown>` return on `Drive.list` with `Promise<DriveListPage>` where `DriveListPage = { entries: DriveEntry[]; cursor?: string }`.
- [ ] 5.2 Add `Drive.scan(prefix?, opts?): AsyncIterable<DriveEntry>` that auto-paginates and respects `signal`.
## 6. Watch
- [ ] 6.1 New module `src/file-operations/watch.ts` + `.md`: `Drive.watch(path, opts?)` returns `{ events: AsyncIterable<DriveChangeEvent>; unsubscribe(): void }`. Backed by the injected `EventBusLike`. Falls back to a per-drive in-memory bus when none injected (drive-local fan-out only).
- [ ] 6.2 Emitters on `write`/`delete`/`copy`/`move` publish `DriveChangeEvent` to bus topic `drive:change:<path>`.
## 7. Plugin lifecycle
- [ ] 7.1 Extend `PluginRegistry` with `unload(name)`, `list()`, `find(name)` and an ordered handle map keyed by `plugin.name`.
- [ ] 7.2 Expose `Drive.unloadPlugin(name)`, `Drive.listPlugins()`, `Drive.findPlugin(name)` on the class. Unload is idempotent; unknown name throws `DriveError(DRIVE.PLUGIN_FAILED, ...)`.
- [ ] 7.3 Document install/unload ordering invariants in `src/plugins/registry.md`.
## 8. Quota / usage
- [ ] 8.1 New module `src/observability/usage.ts` + `.md`: `Drive.usage(): Promise<DriveUsage>` aggregating per-storage byte counts via `storage.usage?()` when available, else by summing `stat().size` over `scan()`. Cached for `options.usageCacheMs` (default 5s).
- [ ] 8.2 New module `src/observability/quota.ts` + `.md`: `Drive.setQuota({ soft?, hard? })`. On every `write`/`copy`/`move`, post-op `usage()` check; emit `drive:quota:warning` on soft trip and throw `DriveError(DRIVE.QUOTA_EXCEEDED)` on hard.
## 9. Sharing-integration hook
- [ ] 9.1 In `src/types/drive.types.ts` add `DriveShareLike` interface (`share(path, audience): Promise<ShareHandle>`, `revoke(handle): Promise<void>`, `listShares(path?): Promise<ShareHandle[]>`). Drive owns the interface; concrete impl lives in `drive-sharing`.
- [ ] 9.2 Add `options.shareHook?: DriveShareLike` to `DriveOptions`; expose `Drive.share`/`Drive.revoke`/`Drive.listShares` that delegate to the hook or throw `DriveError(DRIVE.PLUGIN_FAILED, "no share hook")`.
## 10. Hydration progress events
- [ ] 10.1 In `src/drive/hydrate-from.ts`, alongside the existing `onProgress` callback, publish `drive:hydrate:progress` events to the EventBus when injected. Add `drive:hydrate:complete` and `drive:hydrate:aborted`.
- [ ] 10.2 Update `hydrate-from.md` examples + add a watch-the-bus advanced example.
## 11. OTEL spans
- [ ] 11.1 Wrap each public op (`read`, `write`, `delete`, `stat`, `list`, `scan`, `copy`, `move`, `watch`, `hydrateFrom`, `usage`) in a span named `drive.<op>` with attributes `drive.path`, `drive.storage.id`, `drive.bytes` where applicable. Use `@opentelemetry/api` directly; no-op when no exporter registered.
## 12. Tests
- [ ] 12.1 Per-feature unit tests (`.test.ts`) for every new module.
- [ ] 12.2 Extend `drive.spec-conformance.test.ts` with scenarios for every new requirement (one scenario per `**Verified by:**` line in the spec delta).
- [ ] 12.3 fast-check property tests: `copy(a,b);read(b) === read(a)`, `move` invariant `(after) !exists(a) && exists(b)`, `usage()` monotonicity across N writes, `watch` delivers every change.
- [ ] 12.4 Verify `bunx turbo test:coverage --filter=@suaveplan/drive` reports 100/100/100/100 (istanbul). No `c8 ignore`.
## 13. Docs
- [ ] 13.1 Sibling `.md` for every new `.ts`, ≥200 words, basic + advanced examples (CLAUDE.md `### Documentation`).
- [ ] 13.2 Update `packages/drive/drive/README.md` Modules section with every new feature.
- [ ] 13.3 Refresh `src/drive/drive.md` to list the widened surface and the new option fields.
## 14. Pre-ship gate
- [ ] 14.1 `bunx turbo lint typecheck test --filter=@suaveplan/drive` green.
- [ ] 14.2 `bunx turbo test:coverage --filter=@suaveplan/drive` 100%.
- [ ] 14.3 `bunx turbo build --filter=@suaveplan/drive` green.
- [ ] 14.4 Banned-pattern + barrel-shape + test-runner-import scans clean (AGENTS §13 steps 4, 4a, 4b).
- [ ] 14.5 Dependent sweep: `bunx turbo test --filter=...@suaveplan/drive` green.
- [ ] 14.6 `bun scripts/folder-reorg/generate-manifest.ts` + `bun scripts/generate-missing.ts`; refresh `STATUS.md` / `SUMMARY.md`; `bunx markdownlint-cli2 --fix`.
## 15. Archive
- [ ] 15.1 Fold `specs/drive/spec.md` into `openspec/specs/drive/spec.md`.
- [ ] 15.2 Move `openspec/changes/complete-drive/` → `openspec/archive/<yyyy-mm-dd>-complete-drive/`.