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.
|
||||
112
openspec/changes/complete-drive/proposal.md
Normal file
112
openspec/changes/complete-drive/proposal.md
Normal 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). |
|
||||
215
openspec/changes/complete-drive/specs/drive/spec.md
Normal file
215
openspec/changes/complete-drive/specs/drive/spec.md
Normal 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"`
|
||||
94
openspec/changes/complete-drive/tasks.md
Normal file
94
openspec/changes/complete-drive/tasks.md
Normal 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/`.
|
||||
Reference in New Issue
Block a user