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

12 KiB

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"