6.2 KiB
OpenSpec Change: drive-port
- Change ID: drive-port
- Package:
@suaveplan/drive(new) - Status: PROPOSED
- Date: 2026-04-28
Summary
Port @suaveplan/drive from ../suaveplan/packages/universal/drive into genesis as a file-VFS-only core. Drive in genesis exposes paths and bytes — read, write, delete, stat, list, createReadStream, createWriteStream — backed by AbstractStorage from @suaveplan/storage. Chunking, P2P mesh sync, transports, and transfer()-style cross-drive copy live in separate packages that consume drive via its plugin API. There is no StorageAdapter shim — drive sits directly on AbstractStorage. Constructor accepts a storage instance directly; Drive.initialize() calls storage.connect() for storages drive owns; Drive.dispose() closes only those it opened.
Why this is file-only
The previous draft of this proposal carried chunks, transfer() / manager, and capability-aware streaming all in one package. Discussion this session split those into:
| Concern | Package | Layer |
|---|---|---|
| File VFS (paths, bytes, metadata, plugin slot) | @suaveplan/drive |
this change |
| Random-access offset writes on storage | @suaveplan/storage (setPartial) |
storage-set-partial |
| Chunk format, manifest, packer, sig-verify gate | @suaveplan/drive-plugin-chunks |
drive-plugin-chunks-port |
| Peer index, availability gossip, parallel pull | @suaveplan/drive-mesh |
drive-mesh-port |
| Wire — HTTPS / WebRTC / libp2p / SMB-as-storage | @suaveplan/drive-transport-* |
per transport |
Drive proper does not know about chunks, peers, signatures, or wires. It is a thin file abstraction over storage with a plugin slot.
Current state
- Source
@suaveplan/drivev1.1.1 (../suaveplan/packages/universal/drive): ~7 300 LOC, 127 files, ships onlyMemoryStorage, has its own incompatibleStorageAdapterinterface. - Genesis
AbstractStorage(packages/storage/storage/src/abstract/abstract.ts:56) already declaresgetPartial,getItemSize,createReadStream,createWriteStreamas abstract — theMIGRATION.md§6.3 storage-bridge work is already done. NoStorageProviderAdaptershim is created.
What changes
1. Storage contract — DriveStorage is structural
Drive consumes AbstractStorage directly. DriveStorage is a structural view that adds an id: string, optional capabilities, and optional connect/dispose/isConnected lifecycle hooks. See storage-adapter-sketch.md in this folder for the full TypeScript sketch.
2. Constructor overloads
new Drive() // empty, attach storages later
new Drive(storage) // bare AbstractStorage, registered as id "default"
new Drive(storage, options) // storage + options
new Drive({ defaultStorage, plugins, ... }) // options-only form
new Drive(storage) is sugar for new Drive({ defaultStorage: storage }) — observably equivalent.
3. Lifecycle ownership
Drive.initialize() walks this.storages and calls storage.connect?.() only when storage.isConnected?.() !== true. Storages drive itself connects are tracked in connectedByMe: WeakSet<DriveStorage>. Drive.dispose() closes only those — pre-connected storages passed in by the caller are left open. Both initialize and dispose are idempotent.
4. Plugin slot
interface DrivePlugin<THandle = void> {
readonly name: string;
install(drive: Drive): THandle | Promise<THandle>;
dispose?(): void | Promise<void>;
}
const chunks = drive.use(chunksPlugin({ packer, serverPubKey }));
// drive.* — file-level
// chunks.* — chunk-level (via the plugin's returned handle)
drive.use(plugin) returns the plugin's typed handle. Drive's own type surface stays file-level; plugins extend functionality through their handles, not through module augmentation.
Plugins also register middleware on drive's file ops (read, write, delete, stat, list) via drive.useMiddleware(...). Middleware execute outer-first on request, outer-last on response; plugin.dispose() runs in reverse-registration order on Drive.dispose().
5. Generics
class Drive<TStorageMap extends Record<string, DriveStorage> = Record<string, DriveStorage>> {
use<K extends string, S extends DriveStorage>(id: K, storage: S):
Drive<TStorageMap & Record<K, S>>;
getStorage<K extends keyof TStorageMap & string>(id: K): TStorageMap[K];
}
Storage IDs are typed end-to-end. Calls with an unknown id are a TypeScript error.
6. Module-augmentation factories
StorageFactory.register('file', FileSystem) (already in @suaveplan/storage) is the single registration point. Drive.fromConfig({ storage: { type: 'file', config: { ... } } }) resolves through StorageFactory. Drive does not maintain a separate adapter registry.
7. Error codes
DRIVE_CODES = defineCodes("DRIVE", [NOT_INITIALIZED, DISPOSED, STORAGE_CONNECT_FAILED, STORAGE_NOT_FOUND, IO_FAILED, INVALID_PATH, PLUGIN_FAILED] as const). DriveError extends SuaveplanError<DriveErrorCode>. Frozen registry.
8. Error handling boundaries
storage.connect()errors →DriveError(DRIVE.STORAGE_CONNECT_FAILED, { cause })- ops on disposed drive →
DriveError(DRIVE.DISPOSED) - ops before
initialize()→DriveError(DRIVE.NOT_INITIALIZED) getStorage("nope")→DriveError(DRIVE.STORAGE_NOT_FOUND)- plugin install/dispose throw →
DriveError(DRIVE.PLUGIN_FAILED, { cause })
Impact
- New package
packages/drive/drive/at0.1.0. - New OpenSpec capability
driveatopenspec/specs/drive/spec.md. - No changes to
@suaveplan/storage(thesetPartialaddition isstorage-set-partial, a separate change).
Out of scope (covered by sibling changes)
- Chunk format, manifest, packer integration →
drive-plugin-chunks-port - Sparse-file storage layout → requires
storage-set-partial - Mesh peer index / parallel pull →
drive-mesh-port - Wire transports →
drive-transport-https-port,drive-transport-webrtc-port transfer()/DriveManagercross-drive copy → deferred; mesh subsumes most of it@suaveplan/drive-sharingport → separate change@suaveplan/vaultorchestration layer → separate change after drive lands