94 lines
7.3 KiB
Markdown
94 lines
7.3 KiB
Markdown
# Design — `add-drive-uri-writes`
|
|
|
|
Non-normative. The normative contract is `./specs/drive-uri/spec.md`.
|
|
|
|
## Per-scheme operation matrix
|
|
|
|
| Operation | `drive://` | `drive+mesh://` | `drive+template://` |
|
|
|---|---|---|---|
|
|
| `set(key, bytes)` (resolver `write`) | `Drive.write(key, bytes, { signal })` → `void` | `Drive.write(key, bytes, { signal })` → `void`; mesh advertise NOT triggered | throw `DRIVE_URI.TEMPLATE_READ_ONLY` |
|
|
| `delete(key)` (resolver `remove`) | `Drive.delete(key, signal)` → `boolean` | `Drive.delete(key, signal)` → `boolean`; mesh "no-longer-available" advert NOT triggered | throw `DRIVE_URI.TEMPLATE_READ_ONLY` |
|
|
| `clear()` | throw `DRIVE_URI.WRITE_UNSUPPORTED` (Drive-wide wipe footgun) | same | same |
|
|
| `createReadStream` | unchanged | unchanged | unchanged |
|
|
| `get` / `exists` / `list` / `stat` | unchanged | unchanged | unchanged |
|
|
|
|
`clear()` retains `DRIVE_URI.WRITE_UNSUPPORTED` to preserve the existing error contract for that footgun call site. The new `TEMPLATE_READ_ONLY` code is specific to template-backed mutation attempts — operators differentiate "this drive forbids wholesale wipes" (a config-time concern) from "this URI scheme is intrinsically read-only" (an architectural constraint).
|
|
|
|
## Error code transition
|
|
|
|
| Old behaviour | New behaviour | Code |
|
|
|---|---|---|
|
|
| `set` on `drive://` → throw `WRITE_UNSUPPORTED` | succeeds | (success) |
|
|
| `set` on `drive+mesh://` → throw `WRITE_UNSUPPORTED` | succeeds | (success) |
|
|
| `set` on `drive+template://` → throw `WRITE_UNSUPPORTED` | throw `TEMPLATE_READ_ONLY` | `DRIVE_URI.TEMPLATE_READ_ONLY` |
|
|
| `delete` on `drive+template://` → forwarded to underlying `Drive.delete` (silent footgun: would mutate the hydrated cache, breaking the dehydrated semantics) | throw `TEMPLATE_READ_ONLY` | `DRIVE_URI.TEMPLATE_READ_ONLY` |
|
|
| `clear()` on any scheme → throw `WRITE_UNSUPPORTED` | unchanged | `DRIVE_URI.WRITE_UNSUPPORTED` |
|
|
|
|
`DRIVE_URI.WRITE_UNSUPPORTED` is intentionally retained — consumers who already asserted on it for the `clear()` path continue to work; we narrow its meaning rather than churn the constant.
|
|
|
|
## Mesh write — propagation timing and observability
|
|
|
|
`drive+mesh://` writes complete when local persist is durable. Specifically:
|
|
|
|
1. `Drive.write(path, bytes, { signal })` returns when the installed `chunksPlugin` middleware has resolved (encrypted, chunked, persisted to the configured storage).
|
|
2. Mesh re-advertise (`mesh.advertise(fileId, bitset, totalChunks)`) is NOT triggered by the URI surface. This matches direct-`Drive` behaviour and avoids surprising consumers who manage `GenerationTracker` / `AdvertiseThrottle` lifecycles themselves.
|
|
3. The plugin emits a span event `drive_uri.mesh_write_local_only` on every successful `drive+mesh://` write, with attributes `{ scheme: "drive+mesh", path }`. Operators can grep the trace to confirm the contract holds in their environment.
|
|
4. Consumers wanting gossip propagation invoke `mesh.advertise(fileId, ...)` after the write resolves. Propagation thereafter follows the existing `GossipEngine` timing (best-effort, backoff-throttled, observable via the mesh package's own events).
|
|
|
|
This deliberate choice preserves separation of concerns: the URI surface is a routing layer; mesh participation is a Drive-tier policy.
|
|
|
|
## Key-rotation impact (none, by inheritance)
|
|
|
|
A write may produce data encrypted under a NEW `KeySlotPublic` version. The URI surface does no key derivation, no slot resolution, and no grant signing — those live in `chunksPlugin` and in `Drive.hydrateFrom`'s `key:grant` subscription. The read path validates retroactive grants today; nothing in the write path needs to change for new key versions to be honoured on subsequent reads.
|
|
|
|
No new wiring required at the URI tier.
|
|
|
|
## AbortSignal threading
|
|
|
|
- `resolver.write(uri, body, signal?)` passes `signal` to `InstrumentedDriveAdapter.set(key, value, signal)`.
|
|
- `InstrumentedDriveAdapter.set` updates its signature to accept `AbortSignal` (currently typed `set(key, value)` per `StorageLike`; we add the third arg as an optional propagated to `Drive.write({ signal })`). Pre-aborted signals short-circuit to `DRIVE_URI.ABORTED` before any work.
|
|
- `resolver.remove(uri, signal?)` already threads `signal` to `adapter.delete(key)`; we widen the override to accept and forward `signal` to `Drive.delete(path, signal)`.
|
|
|
|
`StorageLike` itself does not specify `signal` on `set`/`delete`. `DriveStorageLikeAdapter` adds the parameter as a tolerated extension; the resolver already passes it via the streaming-write surface (`createWriteStream(key, body, signal)`), so the precedent is established.
|
|
|
|
## File-layout impact
|
|
|
|
New / edited files (under `packages/drive/drive-uri/src/`):
|
|
|
|
```
|
|
adapter/
|
|
drive-storage-like.ts # widen set/delete to accept signal; forward through
|
|
drive-storage-like.md # doc update — note new TEMPLATE_READ_ONLY case
|
|
error-codes/
|
|
error-codes.ts # add TEMPLATE_READ_ONLY to defineCodes
|
|
error-codes.md # update; describe narrowed WRITE_UNSUPPORTED role
|
|
plugins/
|
|
drive/
|
|
drive.streams.ts # remove unconditional WRITE_UNSUPPORTED; per-scheme dispatch
|
|
drive.streams.md # doc update
|
|
drive-template/
|
|
drive-template.ts # override set/delete → TEMPLATE_READ_ONLY
|
|
drive-template.md # doc update
|
|
otel/
|
|
otel.ts # add `mesh_write_local_only` span-event constant
|
|
otel.md # doc update
|
|
README.md # update Features / Modules sections
|
|
tests/integration/
|
|
drive-roundtrip-write.test.ts # NEW — real Drive, write+read cycle
|
|
drive-mesh-roundtrip-write.test.ts # NEW — mesh-configured Drive, assert no implicit advertise
|
|
template-write-rejection.test.ts # NEW — TEMPLATE_READ_ONLY round-trip
|
|
```
|
|
|
|
All new code stays inside the existing 300-line / 500-line file budgets; the changed `drive.streams.ts` currently sits at 148 lines and the per-scheme branching adds ~30 lines.
|
|
|
|
## Trade-offs considered
|
|
|
|
- **Per-scheme `set` dispatch via `if (scheme === "drive+template")` vs. polymorphic adapter subclass.** Chose the inline branch — only one scheme has the constraint and a subclass would duplicate the entire instrumented method body. If a fourth scheme appears, revisit.
|
|
- **Auto-advertise on mesh writes.** Rejected: violates the direct-`Drive` contract, would surprise consumers who run `GenerationTracker` themselves, and forces every mesh-write call site through an advertise budget. The span event gives operators visibility without enforcing policy.
|
|
- **Reusing `WRITE_UNSUPPORTED` for template rejections.** Rejected: it conflates a temporary state (the deferred scope this change closes) with a permanent architectural constraint (templates are signed). Distinct codes let consumers branch correctly.
|
|
- **Removing `WRITE_UNSUPPORTED` entirely.** Rejected: it would churn the public surface (consumers may already assert on it for the `clear()` path) and §2c forbids deleting a registered code without a real reason. Narrowing the role is the right move.
|
|
|
|
## Cross-reference
|
|
|
|
This change is the second consumer of structural `DriveLike` (after `add-drive-range-reads`), strengthening the case for `hoist-drivelike-to-types`. Independent landing — `drive-uri` imports `DriveLike` from `@suaveplan/drive` today and will move to `@suaveplan/types` once `hoist-drivelike-to-types` archives.
|