Initial Commit
This commit is contained in:
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"`
|
||||
Reference in New Issue
Block a user