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,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"`