Initial Commit
This commit is contained in:
197
openspec/archive/2026-04-28-drive-port/storage-adapter-sketch.md
Normal file
197
openspec/archive/2026-04-28-drive-port/storage-adapter-sketch.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# 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:
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
// @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
|
||||
|
||||
```ts
|
||||
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`
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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 } })` |
|
||||
Reference in New Issue
Block a user