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

8.1 KiB

Unified DriveStorage — design sketch

Reference for drive-port. Drive does not define its own StorageAdapter class; it consumes AbstractStorage from @suaveplan/storage directly and layers a thin capability protocol on top.

1. The base contract is already there

packages/storage/storage/src/abstract/abstract.ts:56 declares the abstract surface drive needs:

abstract class AbstractStorage implements StorageLike {
    abstract get(key, signal?):       Promise<Uint8Array | undefined>;
    abstract set(key, value, opts?, signal?): Promise<void>;
    abstract delete(key, signal?):    Promise<boolean>;
    abstract exists(key, signal?):    Promise<boolean>;
    abstract list(opts?, signal?):    Promise<StorageListResult>;
    abstract clear(signal?):          Promise<void>;
    abstract getWithMetadata(key, signal?): Promise<StorageResult<Uint8Array> | null>;
    abstract getMetadata(key, signal?):     Promise<StorageMetadata | null>;
    abstract getSize(signal?):              Promise<number>;
    abstract getPartial(key, offset, length, signal?): Promise<Uint8Array | null>;
    abstract getItemSize(key, signal?):     Promise<number | null>;
    abstract createReadStream(key, signal?):  ReadableStream<Uint8Array>;
    abstract createWriteStream(key, signal?): WritableStream<Uint8Array>;
}

Every method drive's source StorageAdapter provided (read, write, readPartial, size, streams) is already present here. The MIGRATION.md §6.3 enhancement work is a no-op in genesis.

2. The DriveStorage view

// @suaveplan/drive/src/types/drive-storage.types.ts
import type { AbstractStorage } from "@suaveplan/storage/abstract";

export interface StorageCapabilities {
    /** `getPartial` is a native byte-range read (S3, OPFS). */
    partialReads?: boolean;
    /** `createReadStream`/`createWriteStream` are native (no buffer-and-chunk emulation). */
    nativeStreams?: boolean;
    /** `set` is atomic (write-or-rollback, no partial visibility). */
    atomicWrites?: boolean;
    /** Keys are content-derived hashes (chunk maps can be cross-mounted without re-read). */
    contentAddressable?: boolean;
    /** `connect` MUST be called before use; `dispose` releases handles. */
    requiresLifecycle?: boolean;
}

export interface DriveStorage extends AbstractStorage {
    /** Stable identifier used by `Drive.use(id, storage)` and `getStorage(id)`. */
    readonly id: string;
    /** Optional self-description; absent flags = treat as `false`. */
    readonly capabilities?: StorageCapabilities;
    /** Optional lifecycle hooks (drive only invokes them when defined and `isConnected?.() !== true`). */
    connect?(): Promise<void>;
    dispose?(): Promise<void>;
    isConnected?(): boolean;
}

DriveStorage is structural — any AbstractStorage subclass with an id field satisfies it; capabilities and lifecycle hooks are duck-typed and probed at runtime.

3. How drive uses capabilities

async readPartial(path: string, offset: number, length: number, signal?: AbortSignal) {
    const storage = this.resolve(path);
    if (storage.capabilities?.partialReads) {
        const slice = await storage.getPartial(path, offset, length, signal);
        if (slice !== null) return slice;
    }
    const full = await storage.get(path, signal);
    if (full === undefined) throw new DriveError(DRIVE_CODES.IO_FAILED, `not found: ${path}`);
    return full.slice(offset, offset + length);
}

The capability flag is the contract; the inherited defaultCreate*Stream from AbstractStorage already provides the emulation fallback, so drive doesn't reimplement it.

4. Lifecycle in Drive

class Drive<TStorageMap extends Record<string, DriveStorage>> {
    readonly #storages = new Map<string, DriveStorage>();
    readonly #connectedByMe = new WeakSet<DriveStorage>();
    #state: "fresh" | "ready" | "disposed" = "fresh";

    async initialize(): Promise<void> {
        if (this.#state === "ready") return;
        if (this.#state === "disposed") throw new DriveError(DRIVE_CODES.DISPOSED);
        for (const storage of this.#storages.values()) {
            if (storage.isConnected?.() === true) continue;
            if (typeof storage.connect !== "function") continue;
            try {
                await storage.connect();
                this.#connectedByMe.add(storage);
            } catch (cause) {
                throw new DriveError(
                    DRIVE_CODES.STORAGE_CONNECT_FAILED,
                    `failed to connect storage "${storage.id}"`,
                    { cause },
                );
            }
        }
        this.#state = "ready";
    }

    async dispose(): Promise<void> {
        if (this.#state === "disposed") return;
        for (const storage of this.#storages.values()) {
            if (!this.#connectedByMe.has(storage)) continue;
            await storage.dispose?.();
        }
        this.#state = "disposed";
    }
}

Ownership rule: drive only closes what drive opened. Pre-connected storages passed in by the caller are left open on dispose(). This is the property connectedByMe: WeakSet<DriveStorage> enforces.

5. Generic narrowing

class Drive<TStorageMap extends Record<string, DriveStorage> = {}> {
    use<K extends string, S extends DriveStorage>(
        id: K,
        storage: S,
    ): Drive<TStorageMap & Record<K, S>> {
        this.#storages.set(id, storage);
        return this as unknown as Drive<TStorageMap & Record<K, S>>;
    }

    getStorage<K extends keyof TStorageMap & string>(id: K): TStorageMap[K] {
        const storage = this.#storages.get(id);
        if (!storage) throw new DriveError(DRIVE_CODES.STORAGE_NOT_FOUND, id);
        return storage as TStorageMap[K];
    }
}

Usage:

const drive = new Drive()
    .use("local",  new MemoryStorage({ id: "local" }))
    .use("remote", new S3Storage({ id: "remote", bucket: "vault" }));

const local: MemoryStorage = drive.getStorage("local");   // narrowed
drive.getStorage("nope");                                 // TS error: argument not assignable to keyof TStorageMap

6. Constructor overloads

function Drive(): Drive;
function Drive(storage: DriveStorage): Drive<{ default: DriveStorage }>;
function Drive(storage: DriveStorage, options: DriveOptions): Drive<{ default: DriveStorage }>;
function Drive(options: DriveOptions): Drive;

The bare-storage form is sugar:

new Drive(storage)  // equivalent to new Drive({ defaultStorage: storage })

Both register the storage under id "default" so drive.getStorage("default") works in either path.

7. Plugin middleware

export interface DrivePlugin {
    readonly name: string;
    install(drive: Drive): void;
    dispose?(): void | Promise<void>;
}

export interface DriveMiddleware {
    read?(ctx: ReadCtx,   next: () => Promise<Uint8Array>): Promise<Uint8Array>;
    write?(ctx: WriteCtx, next: () => Promise<void>):       Promise<void>;
    delete?(ctx: DeleteCtx, next: () => Promise<boolean>):  Promise<boolean>;
    stat?(ctx: StatCtx,   next: () => Promise<StorageMetadata | null>): Promise<StorageMetadata | null>;
    list?(ctx: ListCtx,   next: () => Promise<StorageListResult>):      Promise<StorageListResult>;
}

drive.use(plugin) registers in order; middleware execution is outer-first on the request path, outer-last on the response path; plugin.dispose() runs in reverse-registration order during Drive.dispose().

8. What this replaces

Source-side artefact Replaced by
drive/src/storage/adapter.ts (StorageAdapter base class) Direct AbstractStorage consumption + structural DriveStorage interface
drive/src/storage/memory.ts (drive-internal MemoryStorage) MemoryStorage from @suaveplan/storage
storageRegistry inside drive StorageFactory from @suaveplan/storage (single registration point)
MIGRATION.md §6.4 StorageProviderAdapter Not needed — AbstractStorage already exposes the four methods
Constructor flags (enableCow, enableDedup, ...) Plugins via drive.use(cowPlugin())
Hard-wired MemoryStorage default No default — caller must pass a storage or use Drive.fromConfig({ storage: { type, config } })