Initial Commit
This commit is contained in:
70
packages/api-stack/api/api-tenant-provider-memory/README.md
Normal file
70
packages/api-stack/api/api-tenant-provider-memory/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# @suaveplan/api-tenant-provider-memory - In-memory reference implementation of @suaveplan/api-tenant-provider TenantProvider. Backed by an insertion-ordered Map<TenantId, TenantRecord>, ships stable base64url-JSON cursor pagination, active/suspended/archived filtering matching the abstract contract, out-of-band add/remove/clear admin helpers, idempotent dispose, Zod-validated TenantRecord seeds, and the TENANT_PROVIDER_MEMORY.* error registry (DUPLICATE_TENANT, TENANT_NOT_FOUND, INVALID_RECORD). Passes runTenantProviderConformance verbatim. Used by @suaveplan/api test hosts and small static-tenant deployments.
|
||||
|
||||
In-memory reference implementation of the
|
||||
`@suaveplan/api-tenant-provider` `TenantProvider` contract. Backed by
|
||||
an insertion-ordered `Map<TenantId, TenantRecord>`. Production-grade
|
||||
for small static-tenant deployments AND reference-grade for the
|
||||
downstream DB-backed adapters (Stage 8) that ship the same contract.
|
||||
|
||||
The API testing kit (`createTestHost`) uses this provider by
|
||||
default so unit tests never need a database.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The in-memory `TenantProvider` adapter has its own narrow error namespace, distinct from the abstract `TENANT_PROVIDER.…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The in-memory `TenantProvider` adapter has its own narrow error namespace, distinct from the abstract `TENANT_PROVIDER.…
|
||||
- [provider](./src/provider/provider.md) — In-memory reference implementation of the `@suaveplan/api-tenant-provider` `TenantProvider` contract. It is both the de…
|
||||
- [provider](./src/provider/provider.md) — In-memory reference implementation of the `@suaveplan/api-tenant-provider` `TenantProvider` contract. It is both the de…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## What ships
|
||||
|
||||
| Subpath | Module | Doc |
|
||||
|---|---|---|
|
||||
| `./provider` | `createMemoryTenantProvider({ tenants? })` + admin helpers | [`src/provider/provider.md`](src/provider/provider.md) |
|
||||
| `./error-codes` | `TENANT_PROVIDER_MEMORY_CODES` + `TenantProviderMemoryError` | [`src/error-codes/error-codes.md`](src/error-codes/error-codes.md) |
|
||||
|
||||
## Surface
|
||||
|
||||
- `createMemoryTenantProvider(options?)` — factory returning a
|
||||
`MemoryTenantProvider` (the abstract `TenantProvider` interface plus
|
||||
out-of-band `add`/`remove`/`clear` admin helpers).
|
||||
- Active-only filtering by default; `list({ includeSuspended: true })`
|
||||
surfaces suspended tenants; `archived` is reachable only through
|
||||
`lookup`.
|
||||
- Cursor pagination via the canonical
|
||||
`@suaveplan/api-tenant-provider/pagination` codec — cursors produced
|
||||
by this provider round-trip with the abstract decoder.
|
||||
|
||||
## Conformance
|
||||
|
||||
The package's own test file calls
|
||||
`runTenantProviderConformance({ label: "memory", factory: ... })` so
|
||||
every invariant of the abstract contract is verified against this
|
||||
adapter on every CI run.
|
||||
|
||||
## Error namespace
|
||||
|
||||
`TENANT_PROVIDER_MEMORY.*` — three codes:
|
||||
|
||||
- `DUPLICATE_TENANT` — seed or `add()` collision.
|
||||
- `TENANT_NOT_FOUND` — `remove()` of unknown id.
|
||||
- `INVALID_RECORD` — schema failure at the boundary.
|
||||
|
||||
Errors that the abstract contract itself can raise
|
||||
(`TENANT_PROVIDER.INVALID_CURSOR`) flow through unchanged from
|
||||
`@suaveplan/api-tenant-provider`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- DB-backed enumeration — Stage 8 `api-tenant-provider-postgres` /
|
||||
`api-tenant-provider-surrealdb`.
|
||||
- Persistence across process restarts — every `createMemoryTenantProvider`
|
||||
call starts from the supplied seed.
|
||||
|
||||
## Stage
|
||||
|
||||
Stage 4 (Base adapters) of the API plugin system umbrella — see
|
||||
the design document.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@suaveplan/api-tenant-provider-memory",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "In-memory reference implementation of @suaveplan/api-tenant-provider TenantProvider. Backed by an insertion-ordered Map<TenantId, TenantRecord>, ships stable base64url-JSON cursor pagination, active/suspended/archived filtering matching the abstract contract, out-of-band add/remove/clear admin helpers, idempotent dispose, Zod-validated TenantRecord seeds, and the TENANT_PROVIDER_MEMORY.* error registry (DUPLICATE_TENANT, TENANT_NOT_FOUND, INVALID_RECORD). Passes runTenantProviderConformance verbatim. Used by @suaveplan/api test hosts and small static-tenant deployments.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./provider": {
|
||||
"import": {
|
||||
"types": "./dist/provider/provider.d.ts",
|
||||
"default": "./dist/provider/provider.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/api-tenant-provider": "workspace:^",
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# `TENANT_PROVIDER_MEMORY` error codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The in-memory `TenantProvider` adapter has its own narrow error
|
||||
namespace, distinct from the abstract `TENANT_PROVIDER.*` codes shipped
|
||||
by `@suaveplan/api-tenant-provider`. Keeping the registries separate
|
||||
means a caller can tell the difference between "the contract surfaced
|
||||
an error any provider can raise" (`TENANT_PROVIDER.INVALID_CURSOR`) and
|
||||
"the in-memory adapter rejected a mutation" (e.g.
|
||||
`TENANT_PROVIDER_MEMORY.DUPLICATE_TENANT`). The module exports the
|
||||
frozen `TENANT_PROVIDER_MEMORY_CODES` registry, the
|
||||
`TenantProviderMemoryError` class, and the `TenantProviderMemoryErrorCode`
|
||||
union.
|
||||
|
||||
## Features
|
||||
|
||||
- Three stable, dot-namespaced codes: `DUPLICATE_TENANT`,
|
||||
`TENANT_NOT_FOUND`, `INVALID_RECORD`.
|
||||
- `defineCodes("TENANT_PROVIDER_MEMORY", ...)` produces a `Readonly`
|
||||
string-keyed registry the runtime cannot mutate.
|
||||
- `TenantProviderMemoryError extends SuaveplanError<...>` so `instanceof`
|
||||
narrows the `code` field to the three-string union.
|
||||
- A duplicate `tenantProviderMemoryCode` field mirrors `code` for
|
||||
call-sites that want a non-ambiguous accessor.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
TENANT_PROVIDER_MEMORY_CODES,
|
||||
TenantProviderMemoryError,
|
||||
} from "@suaveplan/api-tenant-provider-memory/error-codes";
|
||||
|
||||
throw new TenantProviderMemoryError(
|
||||
TENANT_PROVIDER_MEMORY_CODES.DUPLICATE_TENANT,
|
||||
"tenant acme already exists"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Branch on the narrowed code to choose retry behaviour:
|
||||
|
||||
```ts
|
||||
try {
|
||||
provider.add(record);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof TenantProviderMemoryError &&
|
||||
err.code === TENANT_PROVIDER_MEMORY_CODES.DUPLICATE_TENANT
|
||||
) {
|
||||
return "noop";
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TENANT_PROVIDER_MEMORY_CODES`: `Readonly<{ DUPLICATE_TENANT: "TENANT_PROVIDER_MEMORY.DUPLICATE_TENANT"; TENANT_NOT_FOUND: "TENANT_PROVIDER_MEMORY.TENANT_NOT_FOUND"; INVALID_RECORD: "TENANT_PROVIDER_MEMORY.INVALID_RECORD" }>`.
|
||||
- `TenantProviderMemoryErrorCode`: union of the three dotted strings.
|
||||
- `TenantProviderMemoryError(code, message, options?)`: stores `code`
|
||||
on the base class and `tenantProviderMemoryCode` on the subclass.
|
||||
- `options.cause` flows through `SuaveplanError`'s standard cause chain.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is built via `defineCodes("TENANT_PROVIDER_MEMORY", [...])`
|
||||
which freezes the resulting object at module-load time, ensuring no
|
||||
caller can extend the closed set. The subclass intentionally adds a
|
||||
mirror field rather than just inheriting `.code` so call-sites that
|
||||
catch a generic `SuaveplanError` and want to branch on adapter origin
|
||||
can do so without `instanceof`. All three codes are raised by at least
|
||||
one production path in `provider.ts`; no test-only codes exist.
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-tenant-provider-memory`.
|
||||
*
|
||||
* Every failure path raised directly by the in-memory adapter (as
|
||||
* opposed to errors bubbled through from the abstract contract, which
|
||||
* carry `TENANT_PROVIDER.*` codes) carries one of these
|
||||
* `TENANT_PROVIDER_MEMORY.*` codes:
|
||||
*
|
||||
* - `DUPLICATE_TENANT` — `add(record)` was called with an `id` that is
|
||||
* already present in the backing store.
|
||||
* - `TENANT_NOT_FOUND` — `remove(id)` was called with an unknown id.
|
||||
* - `INVALID_RECORD` — the input failed `tenantRecordSchema` validation
|
||||
* at the boundary (`createMemoryTenantProvider({ tenants })`,
|
||||
* `add(record)`).
|
||||
*
|
||||
* Consumers branch on the string value, e.g.
|
||||
* `error.code === TENANT_PROVIDER_MEMORY_CODES.DUPLICATE_TENANT`.
|
||||
*/
|
||||
export const TENANT_PROVIDER_MEMORY_CODES = defineCodes(
|
||||
"TENANT_PROVIDER_MEMORY",
|
||||
["DUPLICATE_TENANT", "TENANT_NOT_FOUND", "INVALID_RECORD"] as const
|
||||
);
|
||||
|
||||
export type TenantProviderMemoryErrorCode =
|
||||
(typeof TENANT_PROVIDER_MEMORY_CODES)[keyof typeof TENANT_PROVIDER_MEMORY_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class. Extends {@link SuaveplanError} so
|
||||
* `instanceof TenantProviderMemoryError` narrows `code` to
|
||||
* {@link TenantProviderMemoryErrorCode}.
|
||||
*/
|
||||
export class TenantProviderMemoryError extends SuaveplanError<TenantProviderMemoryErrorCode> {
|
||||
public readonly tenantProviderMemoryCode: TenantProviderMemoryErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: TenantProviderMemoryErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.tenantProviderMemoryCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
TENANT_PROVIDER_MEMORY_CODES,
|
||||
TenantProviderMemoryError,
|
||||
type TenantProviderMemoryErrorCode,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
TENANT_PROVIDER_MEMORY_CODES,
|
||||
TenantProviderMemoryError,
|
||||
type TenantProviderMemoryErrorCode,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
type CreateMemoryTenantProviderOptions,
|
||||
createMemoryTenantProvider,
|
||||
type MemoryTenantProvider,
|
||||
} from "./provider/index.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CreateMemoryTenantProviderOptions,
|
||||
createMemoryTenantProvider,
|
||||
type MemoryTenantProvider,
|
||||
} from "./provider.js";
|
||||
@@ -0,0 +1,111 @@
|
||||
# `createMemoryTenantProvider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
In-memory reference implementation of the
|
||||
`@suaveplan/api-tenant-provider` `TenantProvider` contract. It is both
|
||||
the default backing for the API testing kit (so unit tests don't need
|
||||
a database) and a production-grade option for small deployments whose
|
||||
tenant list is small and static, or where a process-restart-bounded
|
||||
seed is acceptable. Backed by an insertion-ordered `Map<TenantId,
|
||||
TenantRecord>` for deterministic `list()` output and `O(1)`
|
||||
`lookup`/`count`.
|
||||
|
||||
## Features
|
||||
|
||||
- Conforms verbatim to `runTenantProviderConformance` from the
|
||||
abstract contract — same active/suspended/archived filtering rules,
|
||||
same `count()` semantics, same cursor-based pagination, same
|
||||
idempotent `dispose()`.
|
||||
- Optional `tenants` seed validated through `tenantRecordSchema` at the
|
||||
boundary; duplicate ids in the seed raise
|
||||
`TENANT_PROVIDER_MEMORY.DUPLICATE_TENANT`.
|
||||
- Out-of-band admin helpers — `add`, `remove`, `clear` — for
|
||||
runtime mutation of the backing store. These sit alongside the
|
||||
abstract `TenantProvider` interface so DB-backed adapters can ship a
|
||||
different (or no) mutation surface without breaking the contract.
|
||||
- Cursor pagination uses the canonical `encodeCursor` / `decodeCursor`
|
||||
helpers from `@suaveplan/api-tenant-provider/pagination`, so cursors
|
||||
produced by other adapters that share the encoding round-trip cleanly.
|
||||
- `ListOpts.pageSize` clamps a single iteration's yield count, mirroring
|
||||
the DB-adapter contract for fan-out callers that want to checkpoint
|
||||
long enumerations.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createMemoryTenantProvider } from "@suaveplan/api-tenant-provider-memory/provider";
|
||||
|
||||
const provider = createMemoryTenantProvider({
|
||||
tenants: [
|
||||
{ id: "acme", status: "active", createdAt: "2026-01-01T00:00:00Z" },
|
||||
{ id: "beta", status: "active", createdAt: "2026-01-02T00:00:00Z" },
|
||||
],
|
||||
});
|
||||
|
||||
for await (const tenant of provider.list()) {
|
||||
console.log(tenant.id);
|
||||
}
|
||||
await provider.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Cursor-based fan-out with checkpointing, plus runtime mutation:
|
||||
|
||||
```ts
|
||||
import { encodeCursor } from "@suaveplan/api-tenant-provider/pagination";
|
||||
import { createMemoryTenantProvider } from "@suaveplan/api-tenant-provider-memory/provider";
|
||||
|
||||
const provider = createMemoryTenantProvider();
|
||||
provider.add({ id: "acme", status: "active", createdAt: "2026-01-01T00:00:00Z" });
|
||||
provider.add({ id: "beta", status: "suspended", createdAt: "2026-01-02T00:00:00Z" });
|
||||
|
||||
// First page — active only
|
||||
for await (const tenant of provider.list({ pageSize: 1 })) {
|
||||
console.log(tenant.id);
|
||||
}
|
||||
|
||||
// Second page — resume from offset
|
||||
const cursor = encodeCursor({ offset: 1, version: 1 });
|
||||
for await (const tenant of provider.list({ cursor, includeSuspended: true })) {
|
||||
console.log(tenant.id);
|
||||
}
|
||||
|
||||
provider.remove("acme");
|
||||
provider.clear();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createMemoryTenantProvider(options?: CreateMemoryTenantProviderOptions): MemoryTenantProvider`.
|
||||
- `CreateMemoryTenantProviderOptions`: `{ tenants?: readonly TenantRecord[] }`.
|
||||
- `MemoryTenantProvider extends TenantProvider`: also exposes
|
||||
`add(record: TenantRecord): void`, `remove(tenantId: TenantId): void`,
|
||||
`clear(): void`.
|
||||
- `list(opts?)`: filters out `archived`, includes `suspended` only when
|
||||
`opts.includeSuspended` is `true`, applies `pageSize` clamp, resumes
|
||||
from `cursor` when set. Invalid cursors raise the contract's
|
||||
`TENANT_PROVIDER.INVALID_CURSOR`.
|
||||
- `count()`: returns the number of `active` records.
|
||||
- `lookup(id)`: returns the record (including `archived`) or `null`.
|
||||
- `dispose()`: idempotent — clears the backing store.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The backing `Map` preserves insertion order per the ECMAScript spec, so
|
||||
`list()` yields tenants in the order they were seeded or added — no
|
||||
sort is required. `dispose` clears the map rather than flagging a
|
||||
disposed bit, which means subsequent `lookup`/`count` calls behave as
|
||||
if the provider were freshly constructed with an empty seed; this is
|
||||
deliberate (the conformance harness requires `dispose` to remain
|
||||
callable). Admin helpers (`add`/`remove`) re-validate inputs through
|
||||
`tenantRecordSchema` so a runtime-mutated provider has the same
|
||||
boundary guarantees as one seeded via the constructor. The `pageSize`
|
||||
clamp is applied at iterator construction (when `Symbol.asyncIterator`
|
||||
is invoked) so a caller can construct multiple iterables from a single
|
||||
provider without sharing state.
|
||||
@@ -0,0 +1,237 @@
|
||||
import { encodeCursor } from "@suaveplan/api-tenant-provider/pagination";
|
||||
import { runTenantProviderConformance } from "@suaveplan/api-tenant-provider/testing";
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
TENANT_PROVIDER_MEMORY_CODES,
|
||||
TenantProviderMemoryError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { createMemoryTenantProvider } from "./provider.js";
|
||||
|
||||
const tid = (s: string) => s as unknown as TenantId;
|
||||
|
||||
function record(
|
||||
id: string,
|
||||
status: "active" | "suspended" | "archived" = "active"
|
||||
) {
|
||||
return {
|
||||
id: tid(id),
|
||||
slug: id,
|
||||
status,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
} as const;
|
||||
}
|
||||
|
||||
runTenantProviderConformance({
|
||||
label: "memory",
|
||||
factory: (seed) => createMemoryTenantProvider({ tenants: seed }),
|
||||
});
|
||||
|
||||
describe("createMemoryTenantProvider — direct", () => {
|
||||
it("seeds in insertion order", { timeout: 5000 }, async () => {
|
||||
const provider = createMemoryTenantProvider({
|
||||
tenants: [record("a"), record("b"), record("c")],
|
||||
});
|
||||
const out: TenantId[] = [];
|
||||
for await (const r of provider.list()) out.push(r.id);
|
||||
expect(out).toEqual([tid("a"), tid("b"), tid("c")]);
|
||||
await provider.dispose();
|
||||
});
|
||||
|
||||
it("count is active-only", { timeout: 5000 }, async () => {
|
||||
const provider = createMemoryTenantProvider({
|
||||
tenants: [
|
||||
record("a", "active"),
|
||||
record("b", "suspended"),
|
||||
record("c", "archived"),
|
||||
],
|
||||
});
|
||||
expect(await provider.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("lookup returns null for unknown id", { timeout: 5000 }, async () => {
|
||||
const provider = createMemoryTenantProvider();
|
||||
expect(await provider.lookup(tid("nope"))).toBeNull();
|
||||
});
|
||||
|
||||
it("lookup returns archived records", { timeout: 5000 }, async () => {
|
||||
const provider = createMemoryTenantProvider({
|
||||
tenants: [record("z", "archived")],
|
||||
});
|
||||
const got = await provider.lookup(tid("z"));
|
||||
expect(got?.id).toBe(tid("z"));
|
||||
});
|
||||
|
||||
it("pageSize limits one iteration", { timeout: 5000 }, async () => {
|
||||
const seed = Array.from({ length: 10 }, (_, i) =>
|
||||
record(`t${String(i).padStart(2, "0")}`, "active")
|
||||
);
|
||||
const provider = createMemoryTenantProvider({ tenants: seed });
|
||||
const acc: TenantId[] = [];
|
||||
for await (const r of provider.list({ pageSize: 4 })) acc.push(r.id);
|
||||
expect(acc).toHaveLength(4);
|
||||
expect(acc).toEqual(seed.slice(0, 4).map((r) => r.id));
|
||||
});
|
||||
|
||||
it("cursor resumes from offset", { timeout: 5000 }, async () => {
|
||||
const seed = Array.from({ length: 5 }, (_, i) =>
|
||||
record(`t${i}`, "active")
|
||||
);
|
||||
const provider = createMemoryTenantProvider({ tenants: seed });
|
||||
const cursor = encodeCursor({ offset: 2, version: 1 });
|
||||
const acc: TenantId[] = [];
|
||||
for await (const r of provider.list({ cursor })) acc.push(r.id);
|
||||
expect(acc).toEqual([tid("t2"), tid("t3"), tid("t4")]);
|
||||
});
|
||||
|
||||
it("invalid ListOpts shape throws ZodError", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = createMemoryTenantProvider();
|
||||
await expect(async () => {
|
||||
for await (const _ of provider.list({
|
||||
pageSize: -1,
|
||||
})) {
|
||||
// unreachable
|
||||
}
|
||||
}).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("dispose drops state and is idempotent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const provider = createMemoryTenantProvider({
|
||||
tenants: [record("a")],
|
||||
});
|
||||
await provider.dispose();
|
||||
await provider.dispose();
|
||||
expect(await provider.count()).toBe(0);
|
||||
expect(await provider.lookup(tid("a"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMemoryTenantProvider — seed validation", () => {
|
||||
it("rejects duplicate id in seed", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
createMemoryTenantProvider({
|
||||
tenants: [record("a"), record("a")],
|
||||
})
|
||||
).toThrow(TenantProviderMemoryError);
|
||||
});
|
||||
|
||||
it("rejects malformed seed record (INVALID_RECORD)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createMemoryTenantProvider({
|
||||
tenants: [
|
||||
{
|
||||
id: "" as unknown as TenantId,
|
||||
slug: "x",
|
||||
status: "active",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TenantProviderMemoryError);
|
||||
expect((err as TenantProviderMemoryError).code).toBe(
|
||||
TENANT_PROVIDER_MEMORY_CODES.INVALID_RECORD
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryTenantProvider — admin helpers", () => {
|
||||
it("add appends a new tenant", { timeout: 5000 }, async () => {
|
||||
const provider = createMemoryTenantProvider();
|
||||
provider.add(record("a"));
|
||||
expect(await provider.count()).toBe(1);
|
||||
expect((await provider.lookup(tid("a")))?.id).toBe(tid("a"));
|
||||
});
|
||||
|
||||
it("add rejects duplicate id (DUPLICATE_TENANT)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = createMemoryTenantProvider({
|
||||
tenants: [record("a")],
|
||||
});
|
||||
try {
|
||||
provider.add(record("a"));
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TenantProviderMemoryError);
|
||||
expect((err as TenantProviderMemoryError).code).toBe(
|
||||
TENANT_PROVIDER_MEMORY_CODES.DUPLICATE_TENANT
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("add rejects malformed record (INVALID_RECORD)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = createMemoryTenantProvider();
|
||||
try {
|
||||
provider.add({
|
||||
id: "" as unknown as TenantId,
|
||||
slug: "x",
|
||||
status: "active",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TenantProviderMemoryError);
|
||||
expect((err as TenantProviderMemoryError).code).toBe(
|
||||
TENANT_PROVIDER_MEMORY_CODES.INVALID_RECORD
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("remove drops a tenant", { timeout: 5000 }, async () => {
|
||||
const provider = createMemoryTenantProvider({
|
||||
tenants: [record("a"), record("b")],
|
||||
});
|
||||
provider.remove(tid("a"));
|
||||
expect(await provider.count()).toBe(1);
|
||||
expect(await provider.lookup(tid("a"))).toBeNull();
|
||||
});
|
||||
|
||||
it("remove of unknown id throws TENANT_NOT_FOUND", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = createMemoryTenantProvider();
|
||||
try {
|
||||
provider.remove(tid("nope"));
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TenantProviderMemoryError);
|
||||
expect((err as TenantProviderMemoryError).code).toBe(
|
||||
TENANT_PROVIDER_MEMORY_CODES.TENANT_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("clear empties the store", { timeout: 5000 }, async () => {
|
||||
const provider = createMemoryTenantProvider({
|
||||
tenants: [record("a"), record("b")],
|
||||
});
|
||||
provider.clear();
|
||||
expect(await provider.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects invalid options shape", { timeout: 5000 }, () => {
|
||||
try {
|
||||
createMemoryTenantProvider({
|
||||
// @ts-expect-error — deliberate boundary breach
|
||||
tenants: "not-an-array",
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TenantProviderMemoryError);
|
||||
expect((err as TenantProviderMemoryError).code).toBe(
|
||||
TENANT_PROVIDER_MEMORY_CODES.INVALID_RECORD
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
decodeCursor,
|
||||
type ListOpts,
|
||||
listOptsSchema,
|
||||
} from "@suaveplan/api-tenant-provider/pagination";
|
||||
import type { TenantProvider } from "@suaveplan/api-tenant-provider/provider";
|
||||
import {
|
||||
type TenantRecord,
|
||||
tenantRecordSchema,
|
||||
} from "@suaveplan/api-tenant-provider/tenant-record";
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import z from "zod";
|
||||
import {
|
||||
TENANT_PROVIDER_MEMORY_CODES,
|
||||
TenantProviderMemoryError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Options handed to {@link createMemoryTenantProvider}.
|
||||
*
|
||||
* `tenants` seeds the backing store in insertion order. Each entry is
|
||||
* parsed through `tenantRecordSchema` so callers can pass loosely-typed
|
||||
* data (e.g. from a YAML config) without losing the strict-Zod boundary.
|
||||
* Default empty array — the resulting provider has zero tenants and the
|
||||
* admin helpers (`add`/`remove`/`clear`) populate it at runtime.
|
||||
*/
|
||||
export interface CreateMemoryTenantProviderOptions {
|
||||
readonly tenants?: readonly TenantRecord[];
|
||||
}
|
||||
|
||||
const optionsSchema = z
|
||||
.object({
|
||||
tenants: z.array(tenantRecordSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* The concrete return type of {@link createMemoryTenantProvider}.
|
||||
*
|
||||
* Extends the abstract {@link TenantProvider} interface with the
|
||||
* out-of-band admin helpers documented by the in-memory adapter:
|
||||
*
|
||||
* - `add(record)` — append a new tenant; throws `DUPLICATE_TENANT` if
|
||||
* `record.id` is already present, `INVALID_RECORD` on schema failure.
|
||||
* - `remove(tenantId)` — drop a tenant; throws `TENANT_NOT_FOUND` if
|
||||
* the id is unknown.
|
||||
* - `clear()` — empty the backing store.
|
||||
*
|
||||
* These mutation methods sit alongside the `TenantProvider` contract
|
||||
* (rather than on it) so DB-backed adapters can ship a different
|
||||
* mutation surface (or none at all) without breaking the contract.
|
||||
*/
|
||||
export interface MemoryTenantProvider extends TenantProvider {
|
||||
add(record: TenantRecord): void;
|
||||
remove(tenantId: TenantId): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an in-memory `TenantProvider` from an optional initial seed.
|
||||
*
|
||||
* Backing store: an internal `Map<TenantId, TenantRecord>` — JavaScript
|
||||
* `Map` preserves insertion order, which gives `list()` deterministic
|
||||
* output without sorting. `lookup`/`count` are `O(1)`.
|
||||
*
|
||||
* The returned object satisfies the abstract `TenantProvider` interface
|
||||
* (`list / count / lookup / dispose`) plus three admin helpers
|
||||
* (`add / remove / clear`). The abstract methods only ever throw
|
||||
* `TenantProviderError` (from the contract package); the admin helpers
|
||||
* throw `TenantProviderMemoryError` from this package.
|
||||
*
|
||||
* Once `dispose()` has resolved, every subsequent abstract or admin
|
||||
* call resolves/returns as if the store were empty — `list()` yields
|
||||
* nothing, `count()` resolves to `0`, `lookup()` resolves to `null`,
|
||||
* and mutation helpers are silent no-ops on the now-cleared store.
|
||||
*/
|
||||
export function createMemoryTenantProvider(
|
||||
options: CreateMemoryTenantProviderOptions = {}
|
||||
): MemoryTenantProvider {
|
||||
const parsed = optionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new TenantProviderMemoryError(
|
||||
TENANT_PROVIDER_MEMORY_CODES.INVALID_RECORD,
|
||||
`createMemoryTenantProvider received invalid options: ${parsed.error.message}`
|
||||
);
|
||||
}
|
||||
const store = new Map<TenantId, TenantRecord>();
|
||||
for (const record of parsed.data.tenants ?? []) {
|
||||
if (store.has(record.id)) {
|
||||
throw new TenantProviderMemoryError(
|
||||
TENANT_PROVIDER_MEMORY_CODES.DUPLICATE_TENANT,
|
||||
`duplicate tenant id ${String(record.id)} in seed`
|
||||
);
|
||||
}
|
||||
store.set(record.id, record);
|
||||
}
|
||||
|
||||
function snapshot(opts: ListOpts | undefined): {
|
||||
readonly rows: TenantRecord[];
|
||||
readonly startOffset: number;
|
||||
} {
|
||||
const includeSuspended = opts?.includeSuspended === true;
|
||||
const startOffset =
|
||||
opts?.cursor !== undefined ? decodeCursor(opts.cursor).offset : 0;
|
||||
const rows: TenantRecord[] = [];
|
||||
for (const record of store.values()) {
|
||||
if (record.status === "archived") continue;
|
||||
if (!includeSuspended && record.status !== "active") continue;
|
||||
rows.push(record);
|
||||
}
|
||||
return { rows, startOffset };
|
||||
}
|
||||
|
||||
return {
|
||||
list(opts?: ListOpts): AsyncIterable<TenantRecord> {
|
||||
// Validate opts at the boundary so a malformed shape fails
|
||||
// fast rather than mid-iteration. `decodeCursor` raises
|
||||
// `TENANT_PROVIDER.INVALID_CURSOR` directly when reached.
|
||||
const validated =
|
||||
opts === undefined ? undefined : listOptsSchema.parse(opts);
|
||||
const pageSize = validated?.pageSize;
|
||||
return {
|
||||
[Symbol.asyncIterator](): AsyncIterator<TenantRecord> {
|
||||
const { rows, startOffset } = snapshot(validated);
|
||||
const upper =
|
||||
pageSize === undefined
|
||||
? rows.length
|
||||
: Math.min(rows.length, startOffset + pageSize);
|
||||
let i = startOffset;
|
||||
return {
|
||||
next(): Promise<IteratorResult<TenantRecord>> {
|
||||
if (i >= upper) {
|
||||
return Promise.resolve({
|
||||
value: undefined as unknown as TenantRecord,
|
||||
done: true,
|
||||
});
|
||||
}
|
||||
const value = rows[i] as TenantRecord;
|
||||
i += 1;
|
||||
return Promise.resolve({ value, done: false });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
count(): Promise<number> {
|
||||
let n = 0;
|
||||
for (const record of store.values()) {
|
||||
if (record.status === "active") n += 1;
|
||||
}
|
||||
return Promise.resolve(n);
|
||||
},
|
||||
lookup(tenantId: TenantId): Promise<TenantRecord | null> {
|
||||
return Promise.resolve(store.get(tenantId) ?? null);
|
||||
},
|
||||
dispose(): Promise<void> {
|
||||
store.clear();
|
||||
return Promise.resolve();
|
||||
},
|
||||
add(record: TenantRecord): void {
|
||||
const result = tenantRecordSchema.safeParse(record);
|
||||
if (!result.success) {
|
||||
throw new TenantProviderMemoryError(
|
||||
TENANT_PROVIDER_MEMORY_CODES.INVALID_RECORD,
|
||||
`add() rejected: ${result.error.message}`
|
||||
);
|
||||
}
|
||||
const parsedRecord = result.data;
|
||||
if (store.has(parsedRecord.id)) {
|
||||
throw new TenantProviderMemoryError(
|
||||
TENANT_PROVIDER_MEMORY_CODES.DUPLICATE_TENANT,
|
||||
`tenant ${String(parsedRecord.id)} already exists`
|
||||
);
|
||||
}
|
||||
store.set(parsedRecord.id, parsedRecord);
|
||||
},
|
||||
remove(tenantId: TenantId): void {
|
||||
if (!store.has(tenantId)) {
|
||||
throw new TenantProviderMemoryError(
|
||||
TENANT_PROVIDER_MEMORY_CODES.TENANT_NOT_FOUND,
|
||||
`tenant ${String(tenantId)} not found`
|
||||
);
|
||||
}
|
||||
store.delete(tenantId);
|
||||
},
|
||||
clear(): void {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* §16 conformance proof — the memory TenantProvider MUST pass
|
||||
* `runTenantProviderConformance` from `@suaveplan/api-tenant-provider/testing`.
|
||||
* The harness exercises insert/list/get/pagination/cursor semantics.
|
||||
*/
|
||||
import { runTenantProviderConformance } from "@suaveplan/api-tenant-provider/testing";
|
||||
import { createMemoryTenantProvider } from "../../src/provider/provider.js";
|
||||
|
||||
runTenantProviderConformance({
|
||||
label: "memory",
|
||||
factory: (seed) => createMemoryTenantProvider({ tenants: seed }),
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-tenant-provider-memory",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user