90 lines
3.3 KiB
Markdown
90 lines
3.3 KiB
Markdown
# OpenSpec Change: storage-set-partial
|
|
|
|
- **Change ID**: storage-set-partial
|
|
- **Package**: `@suaveplan/storage` (modify)
|
|
- **Status**: PROPOSED
|
|
- **Date**: 2026-04-28
|
|
|
|
## Summary
|
|
|
|
Add `setPartial(key, offset, bytes, signal?)` to `AbstractStorage` with a `partialWrites` capability flag and a `defaultSetPartial` read-modify-write fallback. This unblocks `drive-plugin-chunks-port`'s sparse-file layout (pre-allocate a file at full size, drop chunks at deterministic offsets as they arrive) and matches the existing `getPartial` symmetry.
|
|
|
|
## Current state
|
|
|
|
- `packages/storage/storage/src/abstract/abstract.ts:56` declares `getPartial`, `getItemSize`, `createReadStream`, `createWriteStream` as abstract — there is no `setPartial` counterpart.
|
|
- `defaultGetPartial` exists as a protected helper (`abstract.ts:178`); the symmetric `defaultSetPartial` does not.
|
|
- `MemoryStorage` overrides streams with zero-copy variants but has no random-access write API.
|
|
- `StorageCapabilities` is informal; this change pins it as a required readonly property on every storage instance.
|
|
|
|
## What changes
|
|
|
|
### 1. Abstract method addition
|
|
|
|
```ts
|
|
abstract setPartial(
|
|
key: string,
|
|
offset: number,
|
|
bytes: Uint8Array,
|
|
signal?: AbortSignal,
|
|
): Promise<void>;
|
|
```
|
|
|
|
Semantics:
|
|
|
|
- Writes `bytes` at `offset` within the storage object at `key`.
|
|
- If the object does not exist, MUST create one large enough to hold `[offset, offset + bytes.byteLength)`. Bytes outside the written range are zero-valued (`0x00`) on read.
|
|
- If the object exists and is shorter than `offset + bytes.byteLength`, MUST extend it; intermediate bytes are zero-valued.
|
|
- Concurrent `setPartial` calls on the same key are NOT required to be atomic; callers MUST serialise per-key.
|
|
- AbortSignal honoured on a best-effort basis.
|
|
|
|
### 2. Default fallback
|
|
|
|
```ts
|
|
protected async defaultSetPartial(key, offset, bytes, signal?) {
|
|
const current = (await this.get(key, signal)) ?? new Uint8Array(0);
|
|
const end = offset + bytes.byteLength;
|
|
const out = new Uint8Array(Math.max(current.byteLength, end));
|
|
out.set(current, 0);
|
|
out.set(bytes, offset);
|
|
await this.set(key, out, undefined, signal);
|
|
}
|
|
```
|
|
|
|
O(N) memory per call; sparse-file consumers prefer native-capable backends.
|
|
|
|
### 3. Capability flag
|
|
|
|
```ts
|
|
readonly capabilities: Readonly<{
|
|
partialReads: boolean;
|
|
partialWrites: boolean;
|
|
nativeStreams: boolean;
|
|
atomicWrites: boolean;
|
|
contentAddressable: boolean;
|
|
}>;
|
|
```
|
|
|
|
Default all `false`. Subclasses override per backend.
|
|
|
|
### 4. Backend wiring (this change)
|
|
|
|
| Backend | `setPartial` | `partialWrites` |
|
|
| --- | --- | --- |
|
|
| `MemoryStorage` | Native: resize `Uint8Array`, `set(bytes, offset)` | `true` |
|
|
| `storage-s3` | `defaultSetPartial` (multipart wrapper deferred) | `false` |
|
|
| Future ports (`storage-file`, `storage-browser-opfs`) | Native | `true` |
|
|
| Future ports (`storage-browser` IndexedDB) | `defaultSetPartial` | `false` |
|
|
|
|
## Impact
|
|
|
|
- New abstract method; every concrete subclass MUST provide an impl.
|
|
- `capabilities` becomes required readonly property; constructors set it.
|
|
- `storage-s3` gains the fallback wiring (no behaviour change for non-`setPartial` callers).
|
|
- New conformance tests pinning the contract.
|
|
|
|
## Out of scope
|
|
|
|
- `storage-s3` multipart-upload native impl — follow-up.
|
|
- IndexedDB streaming chunked write — follow-up.
|
|
- Atomicity guarantees — covered by `atomicWrites` flag.
|