Files
suaveplan/openspec/archive/2026-04-28-drive-port/proposal.md
2026-05-27 23:29:53 +01:00

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/drive v1.1.1 (../suaveplan/packages/universal/drive): ~7 300 LOC, 127 files, ships only MemoryStorage, has its own incompatible StorageAdapter interface.
  • Genesis AbstractStorage (packages/storage/storage/src/abstract/abstract.ts:56) already declares getPartial, getItemSize, createReadStream, createWriteStream as abstract — the MIGRATION.md §6.3 storage-bridge work is already done. No StorageProviderAdapter shim 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/ at 0.1.0.
  • New OpenSpec capability drive at openspec/specs/drive/spec.md.
  • No changes to @suaveplan/storage (the setPartial addition is storage-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() / DriveManager cross-drive copy → deferred; mesh subsumes most of it
  • @suaveplan/drive-sharing port → separate change
  • @suaveplan/vault orchestration layer → separate change after drive lands