first commit
This commit is contained in:
95
docs/networking/network/service-discovery/README.md
Normal file
95
docs/networking/network/service-discovery/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# @suaveplan/service-discovery - Universal service discovery primitives: ServiceRegistry with TTL/expiry, query, AbortSignal-aware watch, optional storage persistence and EventBusLike integration. Shared foundation for every network-discovery-* plugin.
|
||||
|
||||
## Summary
|
||||
|
||||
Universal service-registry primitives. Provides the shared
|
||||
`ServiceDescriptor`, `ServiceQuery`, `RegistryEvent`, and `ServiceRegistry`
|
||||
contract consumed by every Wave-2 `network-discovery-*` plugin.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [service-registry](./src/core/service-registry/service-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/service-discovery
|
||||
```
|
||||
|
||||
## Basic usage
|
||||
|
||||
```ts
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
|
||||
const registry = new ServiceRegistry({ ttlMs: 30_000 });
|
||||
|
||||
registry.register({
|
||||
id: "api-1",
|
||||
name: "api",
|
||||
address: "10.0.0.1:8080",
|
||||
status: "healthy",
|
||||
region: "us-east-1",
|
||||
tags: ["v1"],
|
||||
});
|
||||
|
||||
const healthy = registry.query({ name: "api", status: "healthy" });
|
||||
```
|
||||
|
||||
## Advanced usage: watch + bus + persistence
|
||||
|
||||
```ts
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
|
||||
declare const bus: EventBusLike<{
|
||||
"service:registered": import("@suaveplan/service-discovery").ServiceDescriptor;
|
||||
"service:deregistered": import("@suaveplan/service-discovery").ServiceDescriptor;
|
||||
"service:expired": import("@suaveplan/service-discovery").ServiceDescriptor;
|
||||
}>;
|
||||
|
||||
declare const storage: import("@suaveplan/service-discovery").ServiceRegistryStorageLike;
|
||||
|
||||
const registry = new ServiceRegistry({
|
||||
ttlMs: 30_000,
|
||||
maxServices: 5_000,
|
||||
bus,
|
||||
storage,
|
||||
persistKey: "service-registry:v1",
|
||||
});
|
||||
|
||||
await registry.load();
|
||||
|
||||
const ac = new AbortController();
|
||||
(async () => {
|
||||
for await (const ev of registry.watch({ name: "api" }, ac.signal)) {
|
||||
console.log(ev.type, ev.service.id);
|
||||
}
|
||||
})();
|
||||
|
||||
// later:
|
||||
await registry.flush();
|
||||
ac.abort();
|
||||
registry.dispose();
|
||||
```
|
||||
|
||||
## What ships
|
||||
|
||||
- `ServiceRegistry` — in-memory store with TTL expiry, query, AbortSignal-
|
||||
aware `watch()` async iterator, optional persistent backing, optional
|
||||
`EventBusLike` fan-out.
|
||||
- Typed error class `SvcDiscError` + frozen `SVC_DISC_CODES` registry
|
||||
(`DUPLICATE_REGISTRATION` / `QUERY_INVALID` / `REGISTRY_FULL` /
|
||||
`INVALID_OPTIONS`).
|
||||
- Zod schemas at every public boundary.
|
||||
- OTel: `svc_disc.registrations_total{outcome}`, `svc_disc.queries_total`,
|
||||
`svc_disc.query_duration_ms`, `svc_disc.active_services` gauge.
|
||||
|
||||
See each feature module's sibling `.md` for the full surface.
|
||||
@@ -0,0 +1,118 @@
|
||||
# ServiceRegistry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`ServiceRegistry` is the universal in-memory store + watch surface that
|
||||
backs every `network-discovery-*` plugin (Wave 2). It models service
|
||||
registration, query, AbortSignal-aware change streaming, TTL-based expiry,
|
||||
optional persistent backing, and optional event-bus fan-out — all behind a
|
||||
single class with Zod-validated boundaries, OTel instrumentation, and
|
||||
typed `SvcDiscError` failures.
|
||||
|
||||
The registry is intentionally lightweight: no load balancing, no health
|
||||
checks, no DHT — those concerns live in higher-tier plugins and consume
|
||||
this primitive through its `query()` / `watch()` surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Upsert `register()` with optional `strict: true` mode raising
|
||||
`DUPLICATE_REGISTRATION`.
|
||||
- `refresh(id)` updates `lastSeen` only.
|
||||
- `deregister(id)` and `dispose()` for full teardown.
|
||||
- `query(filter)` snapshot returning matching descriptors.
|
||||
- `watch(filter, signal?)` async iterator pushing `registered` /
|
||||
`deregistered` / `expired` events; terminates cleanly on abort or
|
||||
`dispose()`.
|
||||
- TTL expiry on every read; `prune()` for explicit sweeps. Pruned
|
||||
entries emit `expired`.
|
||||
- Capacity guard at `maxServices` (default 10 000) raising `REGISTRY_FULL`.
|
||||
- Optional `storage` + `persistKey` snapshot via `flush()` / `load()`.
|
||||
- Optional `bus` emitting `service:registered` / `:deregistered` /
|
||||
`:expired` topics.
|
||||
- OTel: `svc_disc.registrations_total{outcome}`, `svc_disc.queries_total`,
|
||||
`svc_disc.query_duration_ms`, `svc_disc.active_services` gauge.
|
||||
- Injectable `Clock` from `@suaveplan/types` (no module-scope `Date.now()`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
|
||||
const registry = new ServiceRegistry({ ttlMs: 30_000 });
|
||||
|
||||
registry.register({
|
||||
id: "api-1",
|
||||
name: "api",
|
||||
address: "10.0.0.1:8080",
|
||||
status: "healthy",
|
||||
tags: ["v1"],
|
||||
});
|
||||
|
||||
const healthy = registry.query({ name: "api", status: "healthy" });
|
||||
console.log(healthy.length); // 1
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ServiceRegistry } from "@suaveplan/service-discovery";
|
||||
|
||||
const registry = new ServiceRegistry({
|
||||
ttlMs: 5_000,
|
||||
maxServices: 1_000,
|
||||
});
|
||||
|
||||
const ac = new AbortController();
|
||||
(async () => {
|
||||
for await (const ev of registry.watch({ name: "api" }, ac.signal)) {
|
||||
if (ev.type === "expired") console.warn("api instance gone:", ev.service.id);
|
||||
}
|
||||
})();
|
||||
|
||||
// later, on shutdown:
|
||||
ac.abort();
|
||||
registry.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ServiceRegistry(config?: ServiceRegistryConfig)` — validates via
|
||||
`ServiceRegistryConfigSchema`, throws `SvcDiscError(INVALID_OPTIONS)` on
|
||||
malformed input.
|
||||
- `register(input, options?)` — upsert by default; pass `{ strict: true }` to
|
||||
reject duplicates. Returns the stored `ServiceDescriptor`.
|
||||
- `refresh(id)` — touches `lastSeen` only; returns the new descriptor or
|
||||
`undefined` if unknown / expired.
|
||||
- `deregister(id)` — removes the entry; emits `deregistered`.
|
||||
- `get(id)`, `list()`, `size` — readers (auto-prune).
|
||||
- `query(filter)` — snapshot filter; throws `QUERY_INVALID` on bad shape.
|
||||
- `watch(filter, signal?)` — async iterator of `RegistryEvent`; throws
|
||||
`QUERY_INVALID` on bad shape.
|
||||
- `prune()` — explicit TTL sweep; returns the count removed.
|
||||
- `flush(signal?)` / `load(signal?)` — JSON snapshot through the configured
|
||||
`storage`.
|
||||
- `dispose()` — terminates all watchers, unbinds the OTel gauge.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The watch iterator is a hand-rolled async producer (not an async generator)
|
||||
so it can deliver an event to a pending `await iter.next()` consumer in
|
||||
constant time without scheduling through an additional microtask. Events
|
||||
that arrive while no consumer is awaiting are buffered in an in-order
|
||||
FIFO and flushed on the next `next()` call. The iterator implements
|
||||
`return()` so a `for await ... break;` cleanly removes the watcher slot
|
||||
and detaches the AbortSignal listener.
|
||||
|
||||
TTL pruning is intentionally lazy and runs on every reader. This keeps
|
||||
the registry constant-time-bounded by query rate rather than entry count,
|
||||
and means a registry with no traffic costs nothing in the background.
|
||||
Explicit `prune()` is offered for callers that need a deterministic sweep
|
||||
(e.g. before snapshotting).
|
||||
|
||||
Bus integration uses the structural `EventBusLike<TMap>` type from
|
||||
`@suaveplan/types`. The registry does not depend on `@suaveplan/events`
|
||||
or `@suaveplan/network`, so Wave-2 plugins can wire any bus implementation
|
||||
without inducing a cycle.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the structured error class `SvcDiscError` and the frozen code registry
|
||||
`SVC_DISC_CODES` used by every public boundary of `@suaveplan/service-discovery`.
|
||||
suaveplan policy is to surface failure as discriminated, typed errors keyed by a
|
||||
stable code, so consumers can branch on `error.code` rather than parsing
|
||||
free-form messages or matching `instanceof` chains.
|
||||
|
||||
## Features
|
||||
|
||||
- `SVC_DISC_CODES` registry built with `defineCodes("SVC_DISC", [...])`.
|
||||
- Four codes covering the full failure surface: `DUPLICATE_REGISTRATION`,
|
||||
`QUERY_INVALID`, `REGISTRY_FULL`, `INVALID_OPTIONS`.
|
||||
- `SvcDiscError` extends `SuaveplanError<SvcDiscErrorCode>` so the suaveplan-wide
|
||||
message-resolver, redaction, and OTel span-event adapters all work.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SVC_DISC_CODES,
|
||||
SvcDiscError,
|
||||
} from "@suaveplan/service-discovery";
|
||||
|
||||
try {
|
||||
// some registry op
|
||||
} catch (err) {
|
||||
if (err instanceof SvcDiscError && err.code === SVC_DISC_CODES.REGISTRY_FULL) {
|
||||
// backpressure
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SvcDiscError, SVC_DISC_CODES } from "@suaveplan/service-discovery";
|
||||
|
||||
throw new SvcDiscError(
|
||||
SVC_DISC_CODES.DUPLICATE_REGISTRATION,
|
||||
"service-registry.duplicate-registration",
|
||||
{ cause: new Error("instance id collision") }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SVC_DISC_CODES` — frozen `Record<Code, "SVC_DISC.<CODE>">` produced by
|
||||
`defineCodes`.
|
||||
- `SvcDiscErrorCode` — string-literal union derived from the registry.
|
||||
- `SvcDiscError(code, messageKey, options?)` — constructor; `options.cause`
|
||||
threads the wrapped failure for OTel and redacted logging.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All four codes are intentionally narrow. `INVALID_OPTIONS` covers every
|
||||
Zod-rejected option struct (config, descriptor input). `QUERY_INVALID` is
|
||||
separated from `INVALID_OPTIONS` because `query()` and `watch()` are
|
||||
called every request, while options are validated once at construction —
|
||||
keeping the two distinct lets dashboards differentiate frequent client
|
||||
errors from rare misconfiguration. `REGISTRY_FULL` raises only on
|
||||
`register()` over the soft cap; `refresh()` of an existing id never raises
|
||||
this. `DUPLICATE_REGISTRATION` is reserved for the strict registration
|
||||
path; the registry's default `upsert` semantics treat repeat `register()`
|
||||
calls as refreshes and do not raise.
|
||||
68
docs/networking/network/service-discovery/src/otel/otel.md
Normal file
68
docs/networking/network/service-discovery/src/otel/otel.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# OTel instrumentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Single-source OTel instruments for `@suaveplan/service-discovery`. Centralising
|
||||
counter/histogram/gauge creation here keeps cardinality bounded, avoids the
|
||||
common bug where multiple meters get created under similar names, and gives
|
||||
dashboards a single canonical set of metric IDs to chart.
|
||||
|
||||
## Features
|
||||
|
||||
- `svc_disc.registrations_total{outcome}` counter — outcomes are `registered`,
|
||||
`refreshed`, `rejected` (low-cardinality enum).
|
||||
- `svc_disc.queries_total` counter — incremented once per `query()` call.
|
||||
- `svc_disc.query_duration_ms` histogram — wall-clock latency per query.
|
||||
- `svc_disc.active_services` observable gauge — bound to a registry's `size`
|
||||
getter at construction; the meter pulls on scrape.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordRegistration,
|
||||
recordQuery,
|
||||
recordQueryDuration,
|
||||
} from "@suaveplan/service-discovery";
|
||||
|
||||
recordRegistration("registered");
|
||||
recordQuery();
|
||||
recordQueryDuration(1.42);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { bindActiveServicesGauge } from "@suaveplan/service-discovery";
|
||||
|
||||
const services = new Set<string>();
|
||||
const dispose = bindActiveServicesGauge(() => services.size);
|
||||
|
||||
// ...later, on shutdown:
|
||||
dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordRegistration(outcome)` — increments
|
||||
`svc_disc.registrations_total{outcome}` by 1. `outcome` is the
|
||||
`RegistrationOutcome` union.
|
||||
- `recordQuery()` — increments `svc_disc.queries_total` by 1.
|
||||
- `recordQueryDuration(ms)` — records into `svc_disc.query_duration_ms`. Values
|
||||
below zero are clamped to 0.
|
||||
- `bindActiveServicesGauge(getSize)` — registers an OTel observable-gauge
|
||||
callback. Returns a disposer that unbinds the callback.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `outcome` label is restricted to three string values so dashboards can
|
||||
chart success vs refresh vs rejection without unbounded cardinality. The
|
||||
gauge is observable (not synchronous) because the canonical "current size"
|
||||
of the registry is best read on scrape — that way no separate metric write
|
||||
is required on every add/delete and the gauge cannot drift from the actual
|
||||
state. `bindActiveServicesGauge` returns its own disposer so registries can
|
||||
properly tear down their instrumentation on `dispose()` without leaking
|
||||
callbacks across test runs.
|
||||
92
docs/networking/network/service-discovery/src/types/types.md
Normal file
92
docs/networking/network/service-discovery/src/types/types.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Shared type contracts for `@suaveplan/service-discovery`. These types model the
|
||||
universal service-registry domain: descriptors that identify and address a
|
||||
service instance, queries that filter the registry, and registry events that
|
||||
flow through the watch stream and optional event bus. Keeping the types in a
|
||||
single sibling module means every Wave-2 discovery plugin (`crdt`, `peerjs`,
|
||||
`trystero`, `webtorrent`, `gundb`, `hybrid`) can share the same descriptor
|
||||
shape and event topics without re-declaring them.
|
||||
|
||||
## Features
|
||||
|
||||
- `ServiceDescriptor` — identity + routing + metadata + registry-managed
|
||||
timestamps.
|
||||
- `ServiceStatus` — `healthy` / `unhealthy` / `starting` / `stopping`.
|
||||
- `ServiceQuery` — AND-conjoined filter by name / status / region / tag-superset.
|
||||
- `RegistryEvent` — discriminated union of `registered` / `deregistered` /
|
||||
`expired` emitted by the watch stream.
|
||||
- `ServiceRegistryStorageLike` — structural subset of `@suaveplan/storage`
|
||||
`AbstractStorage`, opt-in for cross-restart persistence.
|
||||
- `ServiceRegistryEventMap` — bus topics consumed via the structural
|
||||
`EventBusLike<TMap>` from `@suaveplan/types`.
|
||||
- `ServiceRegistryConfig` — registry constructor options.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ServiceDescriptor,
|
||||
ServiceQuery,
|
||||
} from "@suaveplan/service-discovery";
|
||||
|
||||
const svc: ServiceDescriptor = {
|
||||
id: "api-1",
|
||||
name: "api",
|
||||
address: "10.0.0.1:8080",
|
||||
status: "healthy",
|
||||
registeredAt: 0,
|
||||
lastSeen: 0,
|
||||
};
|
||||
|
||||
const query: ServiceQuery = { name: "api", status: "healthy" };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ServiceRegistryConfig,
|
||||
ServiceRegistryEventMap,
|
||||
} from "@suaveplan/service-discovery";
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
|
||||
declare const bus: EventBusLike<ServiceRegistryEventMap>;
|
||||
|
||||
const config: ServiceRegistryConfig = {
|
||||
ttlMs: 30_000,
|
||||
maxServices: 5_000,
|
||||
bus,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ServiceStatus` — string-literal union; no methods.
|
||||
- `ServiceDescriptor` — readonly fields; the registry stamps `registeredAt`
|
||||
and `lastSeen` so callers may pass these as `0` when constructing inputs.
|
||||
- `ServiceQuery` — every field optional; an empty query matches every entry.
|
||||
- `RegistryEvent` — `{ type: "registered" | "deregistered" | "expired", service }`.
|
||||
- `ServiceRegistryStorageLike` — `get`/`set`/`delete` keyed by string, value
|
||||
is `Uint8Array`.
|
||||
- `ServiceRegistryConfig.maxServices` — soft cap, default `10_000`. Adding a
|
||||
new service when the registry is full raises `SVC_DISC.REGISTRY_FULL`.
|
||||
- `ServiceRegistryConfig.ttlMs` — default `60_000`; entries older than this
|
||||
are pruned and emit `expired`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The descriptor's `registeredAt` and `lastSeen` are wall-clock milliseconds
|
||||
(`number`) so the registry can use the injectable `Clock` from
|
||||
`@suaveplan/types`. Bus integration uses the structural `EventBusLike<TMap>`
|
||||
type — not a hard dep on `@suaveplan/events` or `@suaveplan/network` — so
|
||||
this package stays universal and avoids cycles when Wave-2 plugins
|
||||
register against the same bus their network node uses. The storage shim
|
||||
matches the `get` / `set` / `delete` slice of `AbstractStorage` so callers
|
||||
can pass a `MemoryStorage`, `OPFSStorage`, or `S3Storage` without the
|
||||
registry depending on `@suaveplan/storage` directly.
|
||||
Reference in New Issue
Block a user