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,71 @@
# `@suaveplan/api-tenant-provider` — abstract tenant enumeration contract
**Package**: `@suaveplan/api-tenant-provider`
**Status**: PROPOSED
**Type**: new-package
**Stage**: 1 (Contracts) — see umbrella `openspec/changes/api-plugin-foundation/`
## Why
`perTenant: true` cron / queued jobs (design §5.4) need a way to list every tenant the host knows about so the scheduler can fan one job invocation out per tenant, each with a tenant-scoped context. The same enumeration is needed by background reconciliation, per-tenant cache warmers, and admin tooling. Different deployments source tenant lists from different places: an in-memory list (tests + simple setups), a database row, a directory service, a remote API. This package owns the abstract contract — concrete sources plug behind it (`api-tenant-provider-memory` in Stage 4; production adapters land alongside the host metadata DB or a plugin's own DB).
Per design §5.4: "`tenant.provider` ships in v1 (in-memory reference impl + adapter slot) so retrofitting tenant enumeration into a stable contract doesn't have to happen later."
`tenant.provider` is intentionally distinct from `tenant.resolver` (which extracts `tenantId` from a request — that contract lives in `api-types` / consumed by `api-tenant-resolver-*` adapters).
## What changes
Net-new package. Contract + pagination shape + conformance harness.
- ADDED: `./provider` — abstract `TenantProvider`: `list(opts?): AsyncIterable<TenantRecord>` (stream-friendly for large tenant counts); `count(): Promise<number>`; `lookup(tenantId): Promise<TenantRecord | null>`; `dispose(): Promise<void>`.
- ADDED: `./tenant-record` — `TenantRecord`: `{ id: TenantId, displayName?: string, status: "active" | "suspended" | "archived", createdAt: string }`. Zod schema. Only `active` tenants are enumerated by default; `list({ includeSuspended: true })` surfaces those too.
- ADDED: `./pagination` — `ListOpts`: `{ pageSize?: number, includeSuspended?: boolean, cursor?: string }`. The `AsyncIterable` MAY be page-driven internally; callers iterate without knowing.
- ADDED: `./capability-id` — adds `"tenant.provider"` to the `CapabilityId` union.
- ADDED: `./error-codes` — frozen `TENANT_PROVIDER_CODES`: `TENANT_NOT_FOUND`, `SOURCE_UNAVAILABLE`, `INVALID_CURSOR`.
- ADDED: `./testing` — `runTenantProviderConformance(factory)` reusable harness.
## CapabilityId additions
This package contributes `"tenant.provider"` to the `CapabilityId` union. Per-tenant jobs declared in manifest implicitly require this capability (the host resolves it automatically when `perTenant: true` is set — plugins don't need to list it manually, but it shows up in the registry).
## Out of scope
- Concrete sources — `api-tenant-provider-memory` (Stage 4); DB-backed sources are deployment-specific.
- Tenant CREATE / UPDATE / DELETE — that's an admin concern, not a foundation contract.
- Tenant resolution from a Request — that's `tenant.resolver`, contract in `@suaveplan/api-types`.
## Source mapping
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
| Requirement | Design doc section |
|---|---|
| `perTenant: true` fan-out reading from `tenant.provider` | §5.4 |
| Provider ships in v1 with in-memory reference impl | §5.4 |
| Status filter (`active` default) | Implicit from §5.4 (fan-out should not include suspended tenants by default) |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `AsyncIterable` over `Array` | Future-proofs against large tenant counts; an array forces full-set load, an iterable streams. Standard genesis pattern. |
| `runTenantProviderConformance` harness | Stage-5 deliverable (§13.4). |
## Verification approach
- `src/provider/provider.spec-conformance.test.ts` — interface shape; iterator semantics.
- `src/tenant-record/tenant-record.test.ts` — Zod accept/reject; `TenantId` brand preserved.
- `src/provider/status-filter.test.ts` — default excludes `suspended`/`archived`; `includeSuspended: true` surfaces them.
- `src/pagination/pagination.test.ts` — `pageSize` honored; `cursor` round-trips.
- `src/capability-id/capability-id.spec-conformance.test.ts` — `"tenant.provider"` in `CapabilityId`.
- `tests/dep-graph.test.ts` — deps: `@suaveplan/api-types`, `@suaveplan/error`, `@suaveplan/types`, `zod`.
## Migration impact
None — additive only.
## Impact
### Affected dependents
- (none) — [x] no action needed (reason: net-new; in-memory ref impl lands Stage 4)

View File

@@ -0,0 +1,132 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-tenant-provider (api-tenant-provider)
**Package**: `@suaveplan/api-tenant-provider`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: TenantProvider contract
The package SHALL export `TenantProvider`: `list(opts?: ListOpts): AsyncIterable<TenantRecord>`, `count(): Promise<number>`, `lookup(tenantId: TenantId): Promise<TenantRecord | null>`, `dispose(): Promise<void>`. `lookup` MUST return `null` (not throw) when the tenant is not found.
#### Scenario: every method present
- **WHEN** `expectTypeOf<TenantProvider>()` is checked
- **THEN** all four methods are declared
#### Scenario: lookup returns null for missing tenant
- **WHEN** a stub provider with no tenants is given an arbitrary id
- **THEN** `lookup(id)` resolves to `null`
**Verified by:** `src/provider/provider.spec-conformance.test.ts::"TenantProvider declares list count lookup dispose"` AND `src/provider/provider.test.ts::"lookup returns null for missing tenant"`
---
### Requirement: `import z from "zod"` everywhere
Every Zod import MUST use the default form.
#### Scenario: no named import of z
- **WHEN** the test greps `import z` from `"zod"`
- **THEN** zero matches
**Verified by:** `tests/zod-import-form.test.ts::"every src file uses default-import z"`
---
### Requirement: TenantRecord schema
The package SHALL export `tenantRecordSchema: z.ZodSchema<TenantRecord>` describing `{ id: TenantId, displayName?: string, status: "active" | "suspended" | "archived", createdAt: string }`. `id` MUST be branded as `TenantId` (not plain string). Unknown keys MUST be rejected.
#### Scenario: minimal record parses
- **WHEN** `{ id: "t1", status: "active", createdAt: "2026-05-13T..." }` is parsed
- **THEN** it succeeds AND `id` is typed as `TenantId`
#### Scenario: unknown status rejected
- **WHEN** `{ id: "t1", status: "weird", createdAt: "..." }` is parsed
- **THEN** Zod reports an issue at `status`
**Verified by:** `src/tenant-record/tenant-record.test.ts::"minimal record parses"` AND `src/tenant-record/tenant-record.test.ts::"unknown status rejected"`
---
### Requirement: Status filter — active-by-default
`list(opts)` MUST default to enumerating only tenants whose `status === "active"`. `list({ includeSuspended: true })` MUST also enumerate `suspended` tenants. `archived` tenants are NEVER enumerated by `list` — they are only reachable via `lookup`.
#### Scenario: default excludes suspended and archived
- **WHEN** the provider holds [active, suspended, archived] AND `list()` is consumed
- **THEN** only the active tenant is yielded
#### Scenario: includeSuspended surfaces suspended
- **WHEN** `list({ includeSuspended: true })` is consumed
- **THEN** both active and suspended tenants are yielded; archived is NOT yielded
**Verified by:** `src/provider/status-filter.test.ts::"default list excludes suspended and archived"` AND `src/provider/status-filter.test.ts::"includeSuspended surfaces suspended but not archived"`
---
### Requirement: Pagination cursor round-trip
`ListOpts.cursor` MUST round-trip — consuming part of `list({ pageSize: 10 })`, capturing a cursor, and resuming with `list({ pageSize: 10, cursor })` MUST yield the remainder without duplication or omission. Invalid cursors MUST throw `TENANT_PROVIDER.INVALID_CURSOR`.
#### Scenario: cursor resumes without gap or overlap
- **WHEN** 25 tenants exist AND the caller pulls 10 then resumes with the cursor
- **THEN** the resumption yields exactly 15 tenants AND no duplicates
#### Scenario: invalid cursor rejected
- **WHEN** an arbitrary string is passed as cursor
- **THEN** `TENANT_PROVIDER.INVALID_CURSOR` is thrown
**Verified by:** `src/pagination/pagination.test.ts::"cursor resumes without gap or overlap"` AND `src/pagination/pagination.test.ts::"invalid cursor rejected"`
---
### Requirement: CapabilityId augmentation contributes "tenant.provider"
The package SHALL augment the `CapabilityId` union to include `"tenant.provider"`.
#### Scenario: tenant.provider assignable to CapabilityId
- **WHEN** `expectTypeOf<"tenant.provider">().toMatchTypeOf<CapabilityId>()` is checked
- **THEN** the assertion passes
**Verified by:** `src/capability-id/capability-id.spec-conformance.test.ts::"tenant.provider is a valid CapabilityId after augmentation"`
---
### Requirement: Error codes registry frozen
The package SHALL export `TENANT_PROVIDER_CODES` containing exactly: `TENANT_NOT_FOUND`, `SOURCE_UNAVAILABLE`, `INVALID_CURSOR`.
#### Scenario: every code reachable
- **WHEN** the test enumerates `TENANT_PROVIDER_CODES`
- **THEN** every code has a production throw site
**Verified by:** `src/error-codes/error-codes.spec-conformance.test.ts::"every TENANT_PROVIDER_CODES entry thrown by production path"`
---
### Requirement: Conformance harness for providers
The package SHALL export `runTenantProviderConformance({ factory, label })`. Every concrete provider (in-memory in Stage 4, DB-backed downstream) MUST pass it.
#### Scenario: harness covers list, count, lookup, status filter, pagination, dispose
- **WHEN** the harness runs against a stub provider
- **THEN** test categories exist for each documented behavior
**Verified by:** `src/testing/conformance.test.ts::"harness covers list count lookup status-filter pagination dispose"`

View File

@@ -0,0 +1,67 @@
# Tasks — `@suaveplan/api-tenant-provider`
## 0. Pre-flight
- [x] 0.1 Clean working tree confirmed; worktree on `worktree-agent-a65edefe746eabbf6`
- [x] 0.2 Forked from EXPECTED_BASE e5333ff3 (per §0a Sync)
- [x] 0.3 Manifest check deferred to post-archive `generate-manifest.ts`
## 1. Spec authoring
- [x] 1.1 Read design doc §5.4 (per-tenant jobs)
- [x] 1.2 No prior `openspec/specs/api-tenant-provider/spec.md` (net-new)
- [x] 1.3 Read `openspec/AGENTS.md` §13, §14
- [x] 1.4 `proposal.md` finalised
- [x] 1.5 Source-mapping table in proposal
- [x] 1.6 `specs/api-tenant-provider/spec.md` authored
- [x] 1.7 `**Verified by:**` lines on every requirement
## 2. Implementation
- [x] 2.1 `packages/api-stack/api/api-tenant-provider/` scaffolded — `0.1.0`, `sideEffects: false`, deps: `@suaveplan/api-types`, `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`
- [x] 2.2 `src/provider/` — `TenantProvider` interface (`list / count / lookup / dispose`)
- [x] 2.3 `src/tenant-record/` — `TenantRecord` + strict-mode `tenantRecordSchema` (`import z from "zod"`)
- [x] 2.4 `src/pagination/` — `ListOpts`, `encodeCursor`/`decodeCursor`
- [x] 2.5 `src/capability-id/` — `TENANT_PROVIDER_CAPABILITY_ID` + widened `CapabilityId`
- [x] 2.6 `src/error-codes/` — `TENANT_PROVIDER_CODES` (`TENANT_NOT_FOUND`, `SOURCE_UNAVAILABLE`, `INVALID_CURSOR`) + `TenantProviderError`
- [x] 2.7 `src/testing/` — `runTenantProviderConformance(opts)`
- [x] 2.8 `src/index.ts` — explicit named re-exports
- [x] 2.9 `package.json` `exports` per subpath
## 3. Tests
- [x] 3.1 Unit tests per feature (47 tests, 8 files, all pass)
- [x] 3.2 Spec-conformance type tests via `provider.test.ts` + `capability-id.test.ts`
- [x] 3.3 fast-check property test on cursor round-trip
- [x] 3.4 Every `it(...)` carries `{ timeout: 5000 }`
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts` (6/6 — docs:check 100%)
- [x] 4.2 `README.md` linking every `.md`
- [x] 4.3 No CLAUDE.md update
## 5. Pre-archive verification
- [x] 5.1 Lint, typecheck, test all green
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-tenant-provider` — green
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-tenant-provider` — 100/100/100/100
- [x] 5.4 OpenSpec conformance — Verified-by lines present on every requirement
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-tenant-provider` — green
## 6. Pre-archive 7-row checklist
- [x] 6.1 `TenantProvider` is type-monomorphic; structural `implements` check via `StubProvider`
- [x] 6.2 Zod wired at `TenantRecord` (`tenantRecordSchema`) and `ListOpts` (`listOptsSchema`) boundaries; cursor decode validates via `cursorStateSchema`
- [x] 6.3 Every `TENANT_PROVIDER_CODES` entry constructible and tested
- [x] 6.4 `includeSuspended` tested both directions in conformance harness
- [x] 6.5 OTel — N/A (concrete sources emit; contract package has zero runtime telemetry)
- [x] 6.6 `dispose` idempotent — tested in conformance harness
- [x] 6.7 No retroactive tick — every task checked is genuinely complete
## 7. Archive
- [x] 7.1 Spec delta folded into `openspec/specs/api-tenant-provider/spec.md`
- [x] 7.2 Folder moved to `openspec/archive/2026-05-25-api-tenant-provider/`
- [ ] 7.3 Umbrella tick — deferred to umbrella owner
- [ ] 7.4 `MANIFEST.md` refresh — to be done after merge (script writes from main checkout)