Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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.

View File

@@ -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"]
}

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
export {
TENANT_PROVIDER_MEMORY_CODES,
TenantProviderMemoryError,
type TenantProviderMemoryErrorCode,
} from "./error-codes.js";

View File

@@ -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";

View File

@@ -0,0 +1,5 @@
export {
type CreateMemoryTenantProviderOptions,
createMemoryTenantProvider,
type MemoryTenantProvider,
} from "./provider.js";

View File

@@ -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.

View File

@@ -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
);
}
});
});

View File

@@ -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();
},
};
}

View File

@@ -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 }),
});

View File

@@ -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"]
}

View File

@@ -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"],
},
},
});

View File

@@ -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,
},
});