diff --git a/.changeset/authorization-core.md b/.changeset/authorization-core.md deleted file mode 100644 index 1f37029..0000000 --- a/.changeset/authorization-core.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@primandproper/authorization": minor ---- - -Add `@primandproper/authorization` — the checking half of platform-go's `authorization`, plus -static policy resolution. - -The design turns on one seam: resolving role names to permissions may do I/O and happens once per -session; checking a permission is a map lookup and happens many times per request. Only resolution -is pluggable, and **checking is synchronous, infallible, and allocation-free** — which is what -keeps a check inside a loop from becoming N round trips, and what makes the same code usable in a -React render. The browser therefore runs the identical checks the server does, so the permission -set gating a handler and the one deciding which controls to draw cannot drift. - -There is deliberately no principal type: enforcement needs authority, not identity. Consumers -bridge their own session with a `GrantsExtractor`, which is also where a multi-scope model -collapses into "these sets, OR'd". Sets granting nothing are dropped, so an administrator acting -on a tenant they do not belong to needs no branch anywhere, and an unpopulated `Grants` denies -everything. - -`hasAll([])` is vacuously `true` — honest set algebra and a live hazard, since a requirement list -that accidentally emptied would authorize everyone. The matrix of which declaration sites guard -the empty case, and how, is documented in one place rather than scattered. - -`StaticPolicyResolver` expands role inheritance transitively, rejects a malformed policy at -construction rather than applying it partially, and lets an unknown role contribute nothing rather -than throwing — so a principal assigned a since-deleted role loses that authority, not the ability -to make requests. - -Server enforcement middleware, a database-backed resolver, and a provider factory are not included -yet; #9 stays open for them. diff --git a/.changeset/database-transactions.md b/.changeset/database-transactions.md deleted file mode 100644 index db1bfe9..0000000 --- a/.changeset/database-transactions.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@primandproper/database": minor ---- - -Add `DatabaseClient.withTransaction(fn)`: several statements on one pinned write connection, -committing when `fn` resolves and rolling back — then re-throwing the original error unchanged — -when it rejects. This is the hard prerequisite for the `outbox` port, whose entire guarantee rests -on the event insert being a statement in the caller's transaction. - -`fn` receives a bare `Queryable` (`query` and nothing else), so it cannot commit, roll back, close -the pool, or outlive the closure — lifecycle stays with the method that owns it. `QueryablePool` -now extends `Queryable`, so a helper written against the narrow type accepts a pool or a -transaction, which is what lets `outbox.enqueue` make "holding a `Queryable`" _mean_ "inside a -transaction". `DatabaseClient.query` widened to `Queryable` for the same reason, so statements -inside a transaction are instrumented like any other. - -A transaction is a pinned connection rather than a pool, so `QueryablePool` gains an optional -`connect()` and each adapter supplies it — `pool.connect()` for pg, `getConnection()` for mysql, -and a write lock for sqlite, which has one connection and no nested transactions (a second `BEGIN` -would silently join the first one's scope). A pool that cannot pin a connection throws -`TransactionsUnsupportedError` instead of spreading the statements across arbitrary pooled -connections, which is the failure that looks like it worked. The connection is released in a -`finally` — including when `BEGIN`, the callback, the rollback, or the commit failed — since one -leaked connection per transaction exhausts the pool and presents as unrelated latency. - -Documented and tested behaviour: a failed rollback is logged rather than replacing the error that -caused it; a failed `COMMIT` raises `TransactionCommitError` (distinct in kind — the outcome is -unknown, not provably absent) and attempts no second rollback; nesting on the same client throws -`NestedTransactionError` rather than flattening two scopes, detected per async context so -concurrent transactions on one client still work, and a different client's transaction may nest. -Covered by a provider-agnostic conformance suite run through the postgres, mysql, and sqlite -adapters. diff --git a/.changeset/drop-server-tier.md b/.changeset/drop-server-tier.md new file mode 100644 index 0000000..f955a3a --- /dev/null +++ b/.changeset/drop-server-tier.md @@ -0,0 +1,13 @@ +--- +--- + +Scope the module to the browser and Node scripts: removed the 13 server-tier packages +(`authentication`, `authorization`, `database`, `distributedlock`, `email`, `healthcheck`, +`idempotency`, `llm`, `messagequeue`, `notifications`, `search`, `secrets`, `uploads`). + +Release-neutral for everything that remains — no kept package imported a dropped one, so no +surviving package's code or public surface changed. The dropped packages simply stop being +published; consumers of a previously published version keep it. + +Pending changesets for `@primandproper/authorization`, `@primandproper/database` and +`@primandproper/idempotency` were removed along with their packages. diff --git a/.changeset/idempotency-port.md b/.changeset/idempotency-port.md deleted file mode 100644 index 0222c64..0000000 --- a/.changeset/idempotency-port.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@primandproper/idempotency": minor ---- - -Add `@primandproper/idempotency`, the port of platform-go's `idempotency`: work runs at most once -per client-supplied key, so a retry of a request whose response was never seen replays the -recorded result instead of charging the card twice. - -The manager implements the four-step claim protocol exactly — read, then lock/re-read/claim/ -unlock, then run the work **outside** the lock, then record or release. The re-read inside the -lock is what makes it correct, and the work stays outside it because a held lock is a held -resource (the postgres provider holds a transaction), lock leases are shorter than real work, and -a lock leaves no evidence when a process dies mid-execution. Claims carry an owner id, so an -execution that outlived its claim cannot overwrite whoever re-claimed the key — -`idempotency.claims.lost` counts that and is the counter to alert on. - -The four outcomes are a discriminated union (`executed` / `replayed` / `in-flight` / -`fingerprint-mismatch`) rather than thrown sentinels: they are expected control flow, and this -matches the repo's optional-over-sentinels stance. Thrown `PlatformError`s are reserved for -genuine failures. Store failure is `fail-closed` by default with `fail-open` opt-in, and the -policy governs **reads only**, as in platform-go: a failed read can be treated as a miss and -carried on from, while a claim that could not be written leaves the completion nothing to prove -ownership against, so a failed claim write or an unreachable locker refuses the request under -either policy. - -The package is isomorphic with a deliberately asymmetric split: the browser entry carries key -minting, fingerprinting, and an `idempotentFetch` wrapper that binds one key to the wrapper (so -building it inside a retry loop is the visible mistake), while the Node entry adds the manager. A -browser has no record store or lock, and a noop stand-in would be idempotency that looks wired up -and guarantees nothing. - -Also exports `withLock`, a scoped acquire/run/release helper over `DistributedLock` that reports -contention as a value rather than throwing. diff --git a/CLAUDE.md b/CLAUDE.md index 2f4713b..b8b8a55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,17 @@ Guidance for Claude Code when working in this repository. ## Project Overview -`@primandproper/platform-ts` is a TypeScript monorepo of isomorphic infrastructure -abstractions — the TypeScript sibling of `platform-go`. Each package exposes a stable -interface with swappable provider implementations selected by config. pnpm workspace, -Turborepo, ESM-only, Node 20+. +`primitives-ts` is a TypeScript monorepo of isomorphic infrastructure abstractions — the +TypeScript sibling of `primitives-go`. Each package exposes a stable interface with +swappable provider implementations selected by config. pnpm workspace, Turborepo, +ESM-only, Node 20+. + +**Scope is the browser and Node scripts.** No service is written in TypeScript — +`platform-go` is the only server tier — so a package whose reason to exist is sitting +beside a database, a broker, an object store or a secret manager does not belong here. +Talking to a service built on `platform-go` is `platform-client-ts`'s job, not this +module's. When in doubt: would a browser tab or a one-off script ever construct this? If +no, it is out of scope. ## Common Commands @@ -26,7 +33,7 @@ Run one package's tests: `pnpm --filter @primandproper/cache test`. ## Package modality (the core architectural rule) -Every package is exactly one of three modalities. This drives its `package.json` +Every package is exactly one of two modalities. This drives its `package.json` `exports`, its tsup entries, and its lint rules. - **Universal** — pure logic, one build, no env-specific code. **No Node built-ins, no DOM @@ -35,8 +42,6 @@ Every package is exactly one of three modalities. This drives its `package.json` Two build entries (`src/index.node.ts`, `src/index.browser.ts`) wiring different default providers behind an **identical** interface + factory signature, so call-site code is copy-paste portable between contexts. e.g. `observability`, `cache`. -- **Server-only** — Node bundle, `node`/`default` exports only (no `browser`). May use Node - built-ins. e.g. `secrets`, `database`. Conditional `exports` shape for isomorphic packages: diff --git a/README.md b/README.md index 741d084..09b365a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,21 @@ -# platform-ts +# primitives-ts -Isomorphic infrastructure abstractions for TypeScript — the sibling of `platform-go`. +Isomorphic infrastructure abstractions for TypeScript — the sibling of `primitives-go`. Each package exposes a stable interface with swappable providers selected by config. Most packages are **isomorphic**: the same import resolves to the right implementation whether it runs on Node or in the browser, so call-site code (e.g. logging) is copy-paste portable -between backend and frontend. +between a script and a page. + +**Scope: the browser and Node scripts.** No service is built in TypeScript — `platform-go` +is the only server tier there is — so this module carries nothing that exists to run beside +a database, a broker or a secret manager. Code that needs to _talk_ to a service built on +`platform-go` wants `platform-client-ts`, not this. ## Packages -Every package is exactly one of three modalities (see `CLAUDE.md`): **universal** (pure -logic, one build), **isomorphic** (same import resolves per-environment), **server-only** -(Node bundle, may use Node built-ins). +Every package is exactly one of two modalities (see `CLAUDE.md`): **universal** (pure +logic, one build) or **isomorphic** (same import resolves per-environment). ### Universal @@ -26,46 +30,31 @@ logic, one build), **isomorphic** (same import resolves per-environment), **serv | `@primandproper/encoding` | `Encoder`/`ServerEncoderDecoder` over JSON, YAML, XML, TOML | | `@primandproper/circuitbreaking` | Circuit breakers (noop + partitioned) | | `@primandproper/version` | Build-time version and VCS metadata | +| `@primandproper/qrcodes` | QR code generation, for TOTP setup flows | ### Isomorphic -| Package | Purpose | -| ------------------------------ | ------------------------------------------------------------------------- | -| `@primandproper/observability` | `Logger` (pino on Node, console in browser) + OTel tracer/meter aliases | -| `@primandproper/cache` | `Cache` (memory/redis on Node, memory/web-storage in browser) | -| `@primandproper/cryptography` | `Encryptor` + `Hasher` over WebCrypto | -| `@primandproper/random` | Cryptographically secure random (hex, base32, base64url) over WebCrypto | -| `@primandproper/compression` | `Compressor` interface with swappable providers | -| `@primandproper/cookies` | `CookieStore` interface with swappable providers | -| `@primandproper/httpclient` | Thin `fetch` wrapper with OpenTelemetry spans | -| `@primandproper/ratelimiting` | `RateLimiter` interface with swappable providers | -| `@primandproper/eventstream` | `EventStream` over SSE and WebSocket | -| `@primandproper/analytics` | `EventReporter` interface with swappable providers | -| `@primandproper/eventcapture` | Non-blocking high-volume event capture draining to a swappable sink | -| `@primandproper/idempotency` | At-most-once execution per client key (manager on Node, keys in either) | -| `@primandproper/authorization` | Synchronous permission checks everywhere, policy resolution on the server | - -### Server-only - -| Package | Purpose | -| -------------------------------- | ---------------------------------------------------------------- | -| `@primandproper/secrets` | `SecretSource` interface with swappable providers | -| `@primandproper/authentication` | Password hashing, TOTP, and tokens | -| `@primandproper/email` | `Email` sending interface with swappable providers | -| `@primandproper/uploads` | `UploadManager` object storage with swappable providers | -| `@primandproper/messagequeue` | `Publisher`/`Consumer` interfaces with swappable providers | -| `@primandproper/notifications` | `AsyncNotifier` publisher + mobile `PushNotificationSender` | -| `@primandproper/distributedlock` | Acquire/release/refresh distributed locks | -| `@primandproper/featureflags` | `FeatureFlagManager` with typed evaluation, OpenFeature-backed | -| `@primandproper/search` | Text + document index/search interfaces with swappable providers | -| `@primandproper/llm` | LLM completions over Anthropic and OpenAI | -| `@primandproper/healthcheck` | `Checker` + `Registry` aggregating component health | -| `@primandproper/qrcodes` | QR code generation, for TOTP setup flows | +| Package | Purpose | +| ------------------------------ | ----------------------------------------------------------------------- | +| `@primandproper/observability` | `Logger` (pino on Node, console in browser) + OTel tracer/meter aliases | +| `@primandproper/cache` | `Cache` (memory/redis on Node, memory/web-storage in browser) | +| `@primandproper/cryptography` | `Encryptor` + `Hasher` over WebCrypto | +| `@primandproper/random` | Cryptographically secure random (hex, base32, base64url) over WebCrypto | +| `@primandproper/compression` | `Compressor` interface with swappable providers | +| `@primandproper/cookies` | `CookieStore` interface with swappable providers | +| `@primandproper/httpclient` | Thin `fetch` wrapper with OpenTelemetry spans | +| `@primandproper/ratelimiting` | `RateLimiter` interface with swappable providers | +| `@primandproper/eventstream` | `EventStream` over SSE and WebSocket | +| `@primandproper/analytics` | `EventReporter` interface with swappable providers | +| `@primandproper/eventcapture` | Non-blocking high-volume event capture draining to a swappable sink | +| `@primandproper/featureflags` | `FeatureFlagManager` with typed evaluation, OpenFeature-backed | -## Parity with platform-go +## Parity with primitives-go -`platform-go` is the source of truth. See [`PORT_PROGRESS.md`](./PORT_PROGRESS.md) for the -full package- and provider-level parity breakdown, scope decisions, and remaining work. +`primitives-go` is the source of truth for behaviour, but **not for scope**: parity is +deliberately partial. A package lands here when something in the browser or a Node script +needs it, and packages that only make sense beside server infrastructure are absent on +purpose rather than pending. ## Development diff --git a/package.json b/package.json index 099b113..59ed3f0 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@primandproper/platform-ts", + "name": "@primandproper/primitives-ts", "version": "0.0.0", "private": true, "type": "module", diff --git a/packages/authentication/CHANGELOG.md b/packages/authentication/CHANGELOG.md deleted file mode 100644 index 7eac956..0000000 --- a/packages/authentication/CHANGELOG.md +++ /dev/null @@ -1,11 +0,0 @@ -# @primandproper/authentication - -## 0.0.2 - -### Patch Changes - -- a124406: Add typed error classes (invalid scrypt cost, password hash, invalid TOTP secret, invalid token length) and validate inputs more strictly. -- Updated dependencies [a124406] -- Updated dependencies [a124406] - - @primandproper/errors@0.0.2 - - @primandproper/observability@0.1.0 diff --git a/packages/authentication/package.json b/packages/authentication/package.json deleted file mode 100644 index 6b69f12..0000000 --- a/packages/authentication/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@primandproper/authentication", - "version": "0.0.2", - "type": "module", - "description": "Server-only authentication: password hashing, TOTP, and tokens.", - "license": "AGPL-3.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/primandproper/platform-ts.git", - "directory": "packages/authentication" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "eslint .", - "clean": "rm -rf dist .turbo" - }, - "dependencies": { - "@primandproper/errors": "workspace:*", - "@primandproper/observability": "workspace:*", - "zod": "3.25.76" - } -} diff --git a/packages/authentication/src/authentication.test.ts b/packages/authentication/src/authentication.test.ts deleted file mode 100644 index f10a8c2..0000000 --- a/packages/authentication/src/authentication.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { - type LogValues, - type Logger, - type MeterProvider, -} from "@primandproper/observability"; -import { describe, expect, it } from "vitest"; - -import { - InvalidScryptCostError, - InvalidTOTPSecretError, - InvalidTokenLengthError, -} from "./errors.js"; -import { ScryptHasher } from "./providers/scrypt.js"; -import { RandomTokenGenerator } from "./providers/tokens.js"; -import { RFC6238TOTP } from "./providers/totp.js"; - -import { providePasswordHasher, provideTOTP, provideTokenGenerator } from "./index.js"; - -describe("ScryptHasher", () => { - // Small parameters keep the suite fast; production defaults are far higher. - const make = (): ScryptHasher => - new ScryptHasher({ cost: 1024, blockSize: 8, parallelization: 1, keyLength: 32 }); - - it("verifies a password it hashed", async () => { - const hasher = make(); - const encoded = await hasher.hash("correct horse battery staple"); - expect(await hasher.verify("correct horse battery staple", encoded)).toBe(true); - }); - - it("rejects a wrong password", async () => { - const hasher = make(); - const encoded = await hasher.hash("correct horse battery staple"); - expect(await hasher.verify("Tr0ub4dor&3", encoded)).toBe(false); - }); - - it("returns false for a malformed encoded string", async () => { - const hasher = make(); - expect(await hasher.verify("anything", "not-a-real-hash")).toBe(false); - expect(await hasher.verify("anything", "scrypt$bogus")).toBe(false); - expect(await hasher.verify("anything", "")).toBe(false); - }); - - it("produces a distinct hash each time from a random salt", async () => { - const hasher = make(); - const a = await hasher.hash("same password"); - const b = await hasher.hash("same password"); - expect(a).not.toBe(b); - expect(await hasher.verify("same password", a)).toBe(true); - expect(await hasher.verify("same password", b)).toBe(true); - }); - - it("is constructed by providePasswordHasher with the scrypt default", () => { - expect(providePasswordHasher()).toBeInstanceOf(ScryptHasher); - }); - - it("rejects a non-power-of-two cost at construction", () => { - expect(() => new ScryptHasher({ cost: 1000 })).toThrow(InvalidScryptCostError); - expect(() => providePasswordHasher({ scrypt: { cost: 1000 } })).toThrow(); - }); - - it("treats a hash with an out-of-range cost as malformed instead of allocating for it", async () => { - const hasher = make(); - // N = 2^30 would drive scrypt to allocate gigabytes; verify must reject it fast, not attempt it. - const salt = Buffer.from("salt").toString("base64"); - const key = Buffer.alloc(32, 1).toString("base64"); - const hostile = `scrypt$N=1073741824,r=8,p=1$${salt}$${key}`; - expect(await hasher.verify("anything", hostile)).toBe(false); - }); -}); - -describe("RFC6238TOTP", () => { - it("verifies a code at the same pinned time", () => { - const totp = new RFC6238TOTP(); - const secret = totp.generateSecret(); - const atMs = 1_700_000_000_000; - const code = totp.generate(secret, atMs); - expect(totp.verify(secret, code, { atMs, window: 0 })).toBe(true); - }); - - it("rejects a code from one period at a far-future period", () => { - const totp = new RFC6238TOTP(); - const secret = totp.generateSecret(); - const atMs = 1_700_000_000_000; - const code = totp.generate(secret, atMs); - expect(totp.verify(secret, code, { atMs: atMs + 3_600_000, window: 1 })).toBe(false); - }); - - it("accepts an adjacent step within the window", () => { - const totp = new RFC6238TOTP(); - const secret = totp.generateSecret(); - const atMs = 1_700_000_000_000; - const code = totp.generate(secret, atMs); - // One period earlier; the default window of 1 should still accept it. - expect(totp.verify(secret, code, { atMs: atMs - 30_000, window: 1 })).toBe(true); - }); - - it("matches the RFC 6238 SHA1 test vector", () => { - const totp = new RFC6238TOTP({ algorithm: "SHA1", digits: 8, period: 30 }); - // RFC 6238 Appendix B seed: ASCII "12345678901234567890", base32-encoded. - const secret = base32OfAscii("12345678901234567890"); - expect(totp.generate(secret, 59_000)).toBe("94287082"); - }); - - it("builds a key URI containing the issuer, account, and secret", () => { - const totp = new RFC6238TOTP(); - const secret = totp.generateSecret(); - const uri = totp.keyUri(secret, "alice@example.com", "Prim & Proper"); - expect(uri.startsWith("otpauth://totp/")).toBe(true); - expect(uri).toContain(encodeURIComponent("Prim & Proper")); - expect(uri).toContain(encodeURIComponent("alice@example.com")); - expect(uri).toContain(`secret=${secret}`); - }); - - it("is constructed by provideTOTP", () => { - expect(provideTOTP()).toBeInstanceOf(RFC6238TOTP); - }); - - it("throws a typed error on an invalid secret without leaking a secret character", () => { - const totp = new RFC6238TOTP(); - const secret = "abc!def"; // '!' is not in the base32 alphabet - let thrown: unknown; - try { - totp.generate(secret); - } catch (err) { - thrown = err; - } - expect(thrown).toBeInstanceOf(InvalidTOTPSecretError); - expect((thrown as Error).message).not.toContain("!"); - }); -}); - -describe("RandomTokenGenerator", () => { - it("generates distinct URL-safe tokens of the expected length", () => { - const tokens = new RandomTokenGenerator(); - const a = tokens.generate(); - const b = tokens.generate(); - expect(a).not.toBe(b); - // base64url of 32 bytes is 43 chars (no padding) and uses only the URL-safe alphabet. - expect(a).toHaveLength(43); - expect(a).toMatch(/^[A-Za-z0-9_-]+$/u); - }); - - it("honors a requested byte length", () => { - const tokens = new RandomTokenGenerator(); - // base64url of 16 bytes is 22 chars. - expect(tokens.generate(16)).toHaveLength(22); - }); - - it("is constructed by provideTokenGenerator", () => { - expect(provideTokenGenerator().generate()).toMatch(/^[A-Za-z0-9_-]+$/u); - }); - - it("rejects a non-positive byte length", () => { - const tokens = new RandomTokenGenerator(); - expect(() => tokens.generate(0)).toThrow(InvalidTokenLengthError); - expect(() => tokens.generate(-8)).toThrow(InvalidTokenLengthError); - expect(() => tokens.generate(1.5)).toThrow(InvalidTokenLengthError); - }); -}); - -describe("ScryptHasher instrumentation", () => { - const make = (deps: { logger: Logger; metrics: MeterProvider }): ScryptHasher => - new ScryptHasher( - { cost: 1024, blockSize: 8, parallelization: 1, keyLength: 32 }, - deps, - ); - - it("counts a successful verification and logs it at debug", async () => { - const { logger, debugs } = recordingLogger(); - const { provider, counts } = recordingMeter(); - const hasher = make({ logger, metrics: provider }); - - const encoded = await hasher.hash("correct horse battery staple"); - expect(await hasher.verify("correct horse battery staple", encoded)).toBe(true); - - expect(counts["authentication.password.verifications:success"]).toBe(1); - expect(counts["authentication.password.verifications:failure"] ?? 0).toBe(0); - expect(debugs.some((d) => d.message === "password verification success")).toBe(true); - }); - - it("counts a failed verification, logs it at debug, and never logs the password", async () => { - const { logger, debugs } = recordingLogger(); - const { provider, counts } = recordingMeter(); - const hasher = make({ logger, metrics: provider }); - - const encoded = await hasher.hash("correct horse battery staple"); - expect(await hasher.verify("Tr0ub4dor&3", encoded)).toBe(false); - - expect(counts["authentication.password.verifications:failure"]).toBe(1); - expect(counts["authentication.password.verifications:success"] ?? 0).toBe(0); - expect(debugs.some((d) => d.message === "password verification failure")).toBe(true); - expect(loggedText(debugs)).not.toContain("Tr0ub4dor&3"); - expect(loggedText(debugs)).not.toContain(encoded); - }); -}); - -describe("RFC6238TOTP instrumentation", () => { - const atMs = 1_700_000_000_000; - - it("counts a successful verification and logs it at debug", () => { - const { logger, debugs } = recordingLogger(); - const { provider, counts } = recordingMeter(); - const totp = new RFC6238TOTP({}, { logger, metrics: provider }); - - const secret = totp.generateSecret(); - const code = totp.generate(secret, atMs); - expect(totp.verify(secret, code, { atMs, window: 0 })).toBe(true); - - expect(counts["authentication.totp.verifications:success"]).toBe(1); - expect(counts["authentication.totp.verifications:failure"] ?? 0).toBe(0); - expect(debugs.some((d) => d.message === "TOTP verification success")).toBe(true); - }); - - it("counts a failed verification, logs it at debug, and never logs the secret or code", () => { - const { logger, debugs } = recordingLogger(); - const { provider, counts } = recordingMeter(); - const totp = new RFC6238TOTP({}, { logger, metrics: provider }); - - const secret = totp.generateSecret(); - const code = totp.generate(secret, atMs); - expect(totp.verify(secret, "000000", { atMs, window: 0 })).toBe(false); - - expect(counts["authentication.totp.verifications:failure"]).toBe(1); - expect(counts["authentication.totp.verifications:success"] ?? 0).toBe(0); - expect(debugs.some((d) => d.message === "TOTP verification failure")).toBe(true); - expect(loggedText(debugs)).not.toContain(secret); - expect(loggedText(debugs)).not.toContain(code); - }); -}); - -interface DebugLine { - message: string; - values: LogValues; -} - -/** A logger that records debug lines with every value `with`/`child`/`withSpan` has accumulated. */ -function recordingLogger(): { logger: Logger; debugs: DebugLine[] } { - const debugs: DebugLine[] = []; - const make = (bound: LogValues): Logger => ({ - debug: (message, values) => { - debugs.push({ message, values: { ...bound, ...values } }); - }, - info: () => undefined, - warn: () => undefined, - error: () => undefined, - with: (values) => make({ ...bound, ...values }), - child: () => make(bound), - withSpan: () => make(bound), - }); - return { logger: make({}), debugs }; -} - -/** Every debug line flattened to text, so a test can assert a secret never appears in any of them. */ -function loggedText(debugs: DebugLine[]): string { - return debugs.map((d) => `${d.message} ${JSON.stringify(d.values)}`).join("\n"); -} - -/** A meter provider that tallies counter `add`s by `${instrument}:${outcome}`. */ -function recordingMeter(): { provider: MeterProvider; counts: Record } { - const counts: Record = {}; - const counter = (name: string) => ({ - add: (value: number, attributes?: { outcome?: string }) => { - const key = `${name}:${attributes?.outcome ?? ""}`; - counts[key] = (counts[key] ?? 0) + value; - }, - }); - const meter = { - createCounter: (name: string) => counter(name), - createUpDownCounter: (name: string) => counter(name), - createHistogram: () => ({ record: () => undefined }), - createGauge: () => ({ record: () => undefined }), - }; - return { - provider: { getMeter: () => meter } as unknown as MeterProvider, - counts, - }; -} - -const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - -/** Test-only base32 encoder for the RFC 6238 ASCII seed. */ -function base32OfAscii(ascii: string): string { - const bytes = Buffer.from(ascii, "ascii"); - let bits = 0; - let value = 0; - let out = ""; - for (const byte of bytes) { - value = (value << 8) | byte; - bits += 8; - while (bits >= 5) { - out += BASE32_ALPHABET.charAt((value >>> (bits - 5)) & 31); - bits -= 5; - } - } - if (bits > 0) { - out += BASE32_ALPHABET.charAt((value << (5 - bits)) & 31); - } - return out; -} diff --git a/packages/authentication/src/config.ts b/packages/authentication/src/config.ts deleted file mode 100644 index 055c6a7..0000000 --- a/packages/authentication/src/config.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { z } from "zod"; - -/** - * scrypt-provider config. `cost` is the CPU/memory factor `N` (must be a power of two), - * `blockSize` is `r`, `parallelization` is `p`, and the remaining fields size the salt and - * derived key. Defaults follow Node's documented recommendations for interactive logins. - */ -export const ScryptConfigSchema = z.object({ - cost: z - .number() - .int() - .positive() - .refine((n) => n > 1 && (n & (n - 1)) === 0, { - message: "scrypt cost must be a power of two greater than one", - }) - .default(16384), - blockSize: z.number().int().positive().default(8), - parallelization: z.number().int().positive().default(1), - keyLength: z.number().int().positive().default(64), - saltLength: z.number().int().positive().default(16), -}); - -export type ScryptConfig = z.infer; - -/** - * Password-hashing config. `scrypt` (default) uses `node:crypto`. `argon2id` is documented as - * the intended stronger provider but needs a native dependency and is not implemented here. - */ -export const PasswordConfigSchema = z.object({ - provider: z.enum(["scrypt"]).default("scrypt"), - scrypt: ScryptConfigSchema.optional(), -}); - -export type PasswordConfig = z.infer; -export type PasswordConfigInput = z.input; - -/** TOTP config (RFC 6238). Defaults match the near-universal authenticator-app expectations. */ -export const TOTPConfigSchema = z.object({ - algorithm: z.enum(["SHA1", "SHA256", "SHA512"]).default("SHA1"), - digits: z.number().int().positive().default(6), - period: z.number().int().positive().default(30), -}); - -export type TOTPConfig = z.infer; -export type TOTPConfigInput = z.input; - -/** Token-generator config. `byteLength` is the default entropy used when a caller omits it. */ -export const TokensConfigSchema = z.object({ - byteLength: z.number().int().positive().default(32), -}); - -export type TokensConfig = z.infer; -export type TokensConfigInput = z.input; diff --git a/packages/authentication/src/errors.ts b/packages/authentication/src/errors.ts deleted file mode 100644 index 4f6428b..0000000 --- a/packages/authentication/src/errors.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { PlatformError } from "@primandproper/errors"; - -/** Thrown when a scrypt cost factor `N` is not a power of two greater than one. */ -export class InvalidScryptCostError extends PlatformError { - constructor(cost: number) { - super( - "authentication/invalid-scrypt-cost", - `scrypt cost must be a power of two greater than one, got ${String(cost)}`, - ); - this.name = "InvalidScryptCostError"; - } -} - -/** Thrown when password hashing fails (e.g. the underlying scrypt call rejects). */ -export class PasswordHashError extends PlatformError { - constructor(cause: unknown) { - super("authentication/password-hash-failed", "hashing password failed", { - cause, - }); - this.name = "PasswordHashError"; - } -} - -/** - * Thrown when a TOTP secret is not valid base32. Deliberately carries no fragment of the - * secret in its message so a decode failure can be logged safely. - */ -export class InvalidTOTPSecretError extends PlatformError { - constructor() { - super("authentication/invalid-totp-secret", "TOTP secret is not valid base32"); - this.name = "InvalidTOTPSecretError"; - } -} - -/** Thrown when a token byte length is not a positive integer. */ -export class InvalidTokenLengthError extends PlatformError { - constructor(byteLength: number) { - super( - "authentication/invalid-token-length", - `token byte length must be a positive integer, got ${String(byteLength)}`, - ); - this.name = "InvalidTokenLengthError"; - } -} diff --git a/packages/authentication/src/index.ts b/packages/authentication/src/index.ts deleted file mode 100644 index b11173a..0000000 --- a/packages/authentication/src/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { ObservabilityDeps } from "@primandproper/observability"; - -import { - PasswordConfigSchema, - type PasswordConfigInput, - TOTPConfigSchema, - type TOTPConfigInput, - TokensConfigSchema, - type TokensConfigInput, -} from "./config.js"; -import type { PasswordHasher } from "./password.js"; -import { ScryptHasher } from "./providers/scrypt.js"; -import { RandomTokenGenerator } from "./providers/tokens.js"; -import { RFC6238TOTP } from "./providers/totp.js"; -import type { TokenGenerator } from "./tokens.js"; -import type { TOTP } from "./totp.js"; - -export * from "./errors.js"; -export * from "./password.js"; -export * from "./totp.js"; -export * from "./tokens.js"; -export * from "./config.js"; -export { ScryptHasher } from "./providers/scrypt.js"; -export { RFC6238TOTP } from "./providers/totp.js"; -export { RandomTokenGenerator } from "./providers/tokens.js"; - -/** - * Validates config and returns the matching {@link PasswordHasher}. Mirrors the Go platform's - * `ProvidePasswordHasher`. Only `scrypt` is implemented; argon2id is a documented future provider. - */ -export function providePasswordHasher( - config?: PasswordConfigInput, - deps?: ObservabilityDeps, -): PasswordHasher { - const cfg = PasswordConfigSchema.parse(config ?? {}); - // Only `scrypt` exists today; argon2id is a documented future provider. - return new ScryptHasher(cfg.scrypt ?? {}, deps); -} - -/** Validates config and returns the matching {@link TOTP}. Mirrors the Go platform's `ProvideTOTP`. */ -export function provideTOTP(config?: TOTPConfigInput, deps?: ObservabilityDeps): TOTP { - const cfg = TOTPConfigSchema.parse(config ?? {}); - return new RFC6238TOTP( - { algorithm: cfg.algorithm, digits: cfg.digits, period: cfg.period }, - deps, - ); -} - -/** - * Validates config and returns the matching {@link TokenGenerator}. Mirrors the Go platform's - * `ProvideTokenGenerator`. - */ -export function provideTokenGenerator(config?: TokensConfigInput): TokenGenerator { - const cfg = TokensConfigSchema.parse(config ?? {}); - return new RandomTokenGenerator({ byteLength: cfg.byteLength }); -} diff --git a/packages/authentication/src/password.ts b/packages/authentication/src/password.ts deleted file mode 100644 index 2379e5e..0000000 --- a/packages/authentication/src/password.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * The password-hashing contract. Implementations encode every parameter needed to verify a - * password into the returned string, so {@link verify} is self-describing and a stored hash - * stays verifiable even after defaults change. A wrong password is `false`, not an error. - */ -export interface PasswordHasher { - /** Hashes a plaintext password into a self-describing encoded string. */ - hash(password: string): Promise; - /** - * Reports whether `password` matches the given encoded hash. Returns `false` — never - * throws — on a wrong password or a malformed encoded string. - */ - verify(password: string, encoded: string): Promise; -} diff --git a/packages/authentication/src/providers/scrypt.ts b/packages/authentication/src/providers/scrypt.ts deleted file mode 100644 index 7af9d35..0000000 --- a/packages/authentication/src/providers/scrypt.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; - -import { - makeMetrics, - makeObserver, - type Metrics, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { InvalidScryptCostError, PasswordHashError } from "../errors.js"; -import type { PasswordHasher } from "../password.js"; - -type Counter = ReturnType; - -const o11yName = "authentication"; - -// Ceilings applied to cost parameters read out of an *untrusted* encoded hash during verify(). -// Without them a hostile hash could name an enormous `N`/`r` and drive scrypt to allocate GBs -// (memory scales as ~128·N·r). These bound legitimate hashes comfortably (defaults are N=16384, -// r=8, p=1) while rejecting adversarial ones as malformed. -const MAX_COST = 1 << 20; // N ceiling (1,048,576) -const MAX_BLOCK_SIZE = 32; // r ceiling -const MAX_PARALLELIZATION = 16; // p ceiling - -/** True when `n` is a power of two greater than one — the requirement scrypt places on `N`. */ -function isPowerOfTwo(n: number): boolean { - return Number.isInteger(n) && n > 1 && (n & (n - 1)) === 0; -} - -export interface ScryptHasherOptions { - /** CPU/memory cost factor `N`; must be a power of two. */ - cost?: number; - /** Block size `r`. */ - blockSize?: number; - /** Parallelization factor `p`. */ - parallelization?: number; - /** Length of the derived key in bytes. */ - keyLength?: number; - /** Length of the random salt in bytes. */ - saltLength?: number; -} - -const PREFIX = "scrypt"; - -function deriveKey( - password: string, - salt: Buffer, - cost: number, - blockSize: number, - parallelization: number, - keyLength: number, -): Promise { - return new Promise((resolve, reject) => { - scrypt( - password, - salt, - keyLength, - { N: cost, r: blockSize, p: parallelization, maxmem: 256 * cost * blockSize }, - (err, derivedKey) => { - if (err) { - reject(err); - return; - } - resolve(derivedKey); - }, - ); - }); -} - -/** - * Hashes passwords with `node:crypto`'s scrypt. Each hash carries its own random salt and its - * cost parameters, encoded as `scrypt$N=...,r=...,p=...$$`, so {@link verify} - * parses the parameters back out and stays valid even after the defaults change. - * - * argon2id is the intended stronger future provider, but it needs a native dependency - * (`node:crypto` exposes no argon2), so it is deliberately not implemented here. - */ -export class ScryptHasher implements PasswordHasher { - readonly #cost: number; - readonly #blockSize: number; - readonly #parallelization: number; - readonly #keyLength: number; - readonly #saltLength: number; - readonly #observer: Observer; - readonly #verifications: Counter; - - constructor(options: ScryptHasherOptions = {}, deps: ObservabilityDeps = {}) { - this.#cost = options.cost ?? 16384; - if (!isPowerOfTwo(this.#cost)) { - throw new InvalidScryptCostError(this.#cost); - } - this.#blockSize = options.blockSize ?? 8; - this.#parallelization = options.parallelization ?? 1; - this.#keyLength = options.keyLength ?? 64; - this.#saltLength = options.saltLength ?? 16; - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#verifications = makeMetrics(o11yName, deps.metrics).counter( - "authentication.password.verifications", - { description: "Password verification attempts, by outcome." }, - ); - } - - async hash(password: string): Promise { - try { - const salt = randomBytes(this.#saltLength); - const key = await deriveKey( - password, - salt, - this.#cost, - this.#blockSize, - this.#parallelization, - this.#keyLength, - ); - const params = `N=${String(this.#cost)},r=${String(this.#blockSize)},p=${String(this.#parallelization)}`; - return `${PREFIX}$${params}$${salt.toString("base64")}$${key.toString("base64")}`; - } catch (err) { - throw new PasswordHashError(err); - } - } - - async verify(password: string, encoded: string): Promise { - return this.#observer.run("verify", async (op) => { - const parsed = parseEncoded(encoded); - if (parsed === undefined) { - this.#verifications.add(1, { outcome: "failure" }); - op.logger().debug("malformed encoded password hash"); - return false; - } - try { - const key = await deriveKey( - password, - parsed.salt, - parsed.cost, - parsed.blockSize, - parsed.parallelization, - parsed.hash.length, - ); - const matched = - key.length === parsed.hash.length && timingSafeEqual(key, parsed.hash); - const outcome = matched ? "success" : "failure"; - this.#verifications.add(1, { outcome }); - op.logger().debug(`password verification ${outcome}`); - return matched; - } catch (err) { - this.#verifications.add(1, { outcome: "failure" }); - op.acknowledge(err, "verifying password hash"); - return false; - } - }); - } -} - -interface ParsedHash { - cost: number; - blockSize: number; - parallelization: number; - salt: Buffer; - hash: Buffer; -} - -function parseEncoded(encoded: string): ParsedHash | undefined { - const parts = encoded.split("$"); - if (parts.length !== 4 || parts[0] !== PREFIX) { - return undefined; - } - const [, rawParams, rawSalt, rawHash] = parts; - - const params = new Map(); - for (const pair of (rawParams ?? "").split(",")) { - const [name, value] = pair.split("="); - const parsed = Number(value); - if (name === undefined || value === undefined || !Number.isInteger(parsed)) { - return undefined; - } - params.set(name, parsed); - } - - const cost = params.get("N"); - const blockSize = params.get("r"); - const parallelization = params.get("p"); - if (cost === undefined || blockSize === undefined || parallelization === undefined) { - return undefined; - } - // Reject cost parameters outside sane bounds so a hostile hash can't drive scrypt to allocate - // GBs. An out-of-range parameter is treated as a malformed hash (verify → false), never a throw. - if ( - !isPowerOfTwo(cost) || - cost > MAX_COST || - blockSize < 1 || - blockSize > MAX_BLOCK_SIZE || - parallelization < 1 || - parallelization > MAX_PARALLELIZATION - ) { - return undefined; - } - - const salt = Buffer.from(rawSalt ?? "", "base64"); - const hash = Buffer.from(rawHash ?? "", "base64"); - if (salt.length === 0 || hash.length === 0) { - return undefined; - } - - return { cost, blockSize, parallelization, salt, hash }; -} diff --git a/packages/authentication/src/providers/tokens.ts b/packages/authentication/src/providers/tokens.ts deleted file mode 100644 index 59e7295..0000000 --- a/packages/authentication/src/providers/tokens.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { randomBytes } from "node:crypto"; - -import { InvalidTokenLengthError } from "../errors.js"; -import type { TokenGenerator } from "../tokens.js"; - -export interface RandomTokenGeneratorOptions { - /** Default entropy in bytes used when {@link RandomTokenGenerator.generate} is called bare. */ - byteLength?: number; -} - -/** Generates base64url tokens from cryptographically secure random bytes. */ -export class RandomTokenGenerator implements TokenGenerator { - readonly #byteLength: number; - - constructor(options: RandomTokenGeneratorOptions = {}) { - this.#byteLength = options.byteLength ?? 32; - } - - generate(byteLength: number = this.#byteLength): string { - if (!Number.isInteger(byteLength) || byteLength <= 0) { - throw new InvalidTokenLengthError(byteLength); - } - return randomBytes(byteLength).toString("base64url"); - } -} diff --git a/packages/authentication/src/providers/totp.ts b/packages/authentication/src/providers/totp.ts deleted file mode 100644 index 240f5da..0000000 --- a/packages/authentication/src/providers/totp.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; - -import { - makeMetrics, - makeObserver, - type Metrics, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { InvalidTOTPSecretError } from "../errors.js"; -import type { TOTP } from "../totp.js"; - -type Counter = ReturnType; - -const o11yName = "authentication"; - -export type TOTPAlgorithm = "SHA1" | "SHA256" | "SHA512"; - -export interface RFC6238TOTPOptions { - /** HMAC algorithm. Authenticator apps almost universally expect SHA1. */ - algorithm?: TOTPAlgorithm; - /** Number of digits in a generated code. */ - digits?: number; - /** Length of a time step in seconds. */ - period?: number; -} - -const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - -/** Encodes bytes as RFC 4648 base32 without padding. */ -function base32Encode(bytes: Buffer): string { - let bits = 0; - let value = 0; - let out = ""; - for (const byte of bytes) { - value = (value << 8) | byte; - bits += 8; - while (bits >= 5) { - out += BASE32_ALPHABET.charAt((value >>> (bits - 5)) & 31); - bits -= 5; - } - } - if (bits > 0) { - out += BASE32_ALPHABET.charAt((value << (5 - bits)) & 31); - } - return out; -} - -/** - * Decodes an RFC 4648 base32 string (padding and case insensitive). Throws - * {@link InvalidTOTPSecretError} on invalid input — deliberately without echoing any character - * of the secret into the error message. - */ -function base32Decode(input: string): Buffer { - const normalized = input.replace(/=+$/u, "").toUpperCase().replace(/\s/gu, ""); - let bits = 0; - let value = 0; - const out: number[] = []; - for (const char of normalized) { - const index = BASE32_ALPHABET.indexOf(char); - if (index === -1) { - throw new InvalidTOTPSecretError(); - } - value = (value << 5) | index; - bits += 5; - if (bits >= 8) { - out.push((value >>> (bits - 8)) & 0xff); - bits -= 8; - } - } - return Buffer.from(out); -} - -function counterBuffer(counter: number): Buffer { - const buf = Buffer.alloc(8); - buf.writeBigUInt64BE(BigInt(counter)); - return buf; -} - -/** Implements TOTP per RFC 6238 (HOTP/RFC 4226 truncation) using `node:crypto` HMAC. */ -export class RFC6238TOTP implements TOTP { - readonly #algorithm: TOTPAlgorithm; - readonly #digits: number; - readonly #period: number; - readonly #observer: Observer; - readonly #verifications: Counter; - - constructor(options: RFC6238TOTPOptions = {}, deps: ObservabilityDeps = {}) { - this.#algorithm = options.algorithm ?? "SHA1"; - this.#digits = options.digits ?? 6; - this.#period = options.period ?? 30; - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#verifications = makeMetrics(o11yName, deps.metrics).counter( - "authentication.totp.verifications", - { description: "TOTP verification attempts, by outcome." }, - ); - } - - generateSecret(bytes = 20): string { - return base32Encode(randomBytes(bytes)); - } - - keyUri(secret: string, accountName: string, issuer: string): string { - const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}`; - const params = new URLSearchParams({ - secret, - issuer, - algorithm: this.#algorithm, - digits: String(this.#digits), - period: String(this.#period), - }); - return `otpauth://totp/${label}?${params.toString()}`; - } - - /** - * Returns the code for `secret` at `atMs`. Throws {@link InvalidTOTPSecretError} when `secret` - * is not valid base32 (rather than letting a raw decode error escape). - */ - generate(secret: string, atMs: number = Date.now()): string { - return this.#codeForCounter(secret, this.#counterAt(atMs)); - } - - /** - * Verifies `code` against `secret` within `±window` time steps. Returns `false` (never throws) - * for a wrong code or a malformed secret. - * - * NOTE: this does **not** prevent replay — a valid code stays valid for the whole step (and any - * accepted `window` neighbours). The caller must record accepted `(user, counter)` pairs and - * reject a second use to make one-time codes actually one-time. - */ - verify( - secret: string, - code: string, - opts: { window?: number; atMs?: number } = {}, - ): boolean { - const window = opts.window ?? 1; - const counter = this.#counterAt(opts.atMs ?? Date.now()); - const op = this.#observer.begin("verify"); - try { - for (let offset = -window; offset <= window; offset++) { - const candidate = this.#codeForCounter(secret, counter + offset); - if (constantTimeEqual(candidate, code)) { - this.#verifications.add(1, { outcome: "success" }); - op.logger().debug("TOTP verification success"); - return true; - } - } - this.#verifications.add(1, { outcome: "failure" }); - op.logger().debug("TOTP verification failure"); - return false; - } catch (err) { - this.#verifications.add(1, { outcome: "failure" }); - op.acknowledge(err, "verifying TOTP code"); - return false; - } finally { - op.end(); - } - } - - #counterAt(atMs: number): number { - return Math.floor(atMs / 1000 / this.#period); - } - - #codeForCounter(secret: string, counter: number): string { - const key = base32Decode(secret); - const digest = createHmac(this.#algorithm, key) - .update(counterBuffer(counter)) - .digest(); - const offset = digest.readUInt8(digest.length - 1) & 0x0f; - const binary = digest.readUInt32BE(offset) & 0x7fffffff; - return (binary % 10 ** this.#digits).toString().padStart(this.#digits, "0"); - } -} - -/** Length-safe constant-time string comparison via {@link timingSafeEqual}. */ -function constantTimeEqual(a: string, b: string): boolean { - const bufA = Buffer.from(a, "utf8"); - const bufB = Buffer.from(b, "utf8"); - if (bufA.length !== bufB.length) { - return false; - } - return timingSafeEqual(bufA, bufB); -} diff --git a/packages/authentication/src/tokens.ts b/packages/authentication/src/tokens.ts deleted file mode 100644 index 042c01a..0000000 --- a/packages/authentication/src/tokens.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * The token-generation contract. Produces URL-safe (base64url) random strings suitable for - * password-reset links, API keys, and session identifiers. - */ -export interface TokenGenerator { - /** Returns a base64url-encoded random token from `byteLength` bytes of entropy (default 32). */ - generate(byteLength?: number): string; -} diff --git a/packages/authentication/src/totp.ts b/packages/authentication/src/totp.ts deleted file mode 100644 index a6c9240..0000000 --- a/packages/authentication/src/totp.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * The TOTP contract (RFC 6238). Secrets are base32 (RFC 4648, no padding) so they round-trip - * through authenticator apps via {@link keyUri}. `atMs` pins the clock for deterministic tests. - */ -export interface TOTP { - /** Returns a fresh base32-encoded secret of the given byte length (default 20). */ - generateSecret(bytes?: number): string; - /** Builds the `otpauth://totp/...` URI an authenticator app scans to enroll the secret. */ - keyUri(secret: string, accountName: string, issuer: string): string; - /** Returns the current code for the secret, or the code at `atMs` when pinned. */ - generate(secret: string, atMs?: number): string; - /** - * Reports whether `code` is valid for the secret within `±window` time steps (default 1), - * optionally at the pinned time `atMs`. Compared in constant time. - */ - verify( - secret: string, - code: string, - opts?: { window?: number; atMs?: number }, - ): boolean; -} diff --git a/packages/authentication/tsconfig.json b/packages/authentication/tsconfig.json deleted file mode 100644 index bdb9327..0000000 --- a/packages/authentication/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022"], - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/authentication/tsup.config.ts b/packages/authentication/tsup.config.ts deleted file mode 100644 index 8bf2394..0000000 --- a/packages/authentication/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { index: "src/index.ts" }, - platform: "node", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: true, -}); diff --git a/packages/authorization/README.md b/packages/authorization/README.md deleted file mode 100644 index e0a6a17..0000000 --- a/packages/authorization/README.md +++ /dev/null @@ -1,157 +0,0 @@ -# @primandproper/authorization - -Answers "may this principal do this thing" — the TypeScript port of platform-go's -`authorization`. `authentication` establishes _who_ is calling; this establishes _what they may -do_. - -## The seam - -Two operations that look like one, and conflating them is the mistake the whole design exists to -avoid: - -``` -resolve role names -> permission set once per session may do I/O -check permission in set many per request never does -``` - -Only resolution is pluggable. **Checking is a map lookup: synchronous, infallible, allocation-free -— and that it cannot fail is the property to defend.** The tempting interface — - -```ts -authorize(principal, action, resource): Promise -``` - -— makes a map lookup and a network round trip indistinguishable at the call site, which is how a -permission check inside a loop becomes N round trips. It adds a rejection path with no cause, and -the tempting way to handle "engine unavailable" is to allow. In TypeScript, synchronous also means -usable directly in a React render, which is the point of the browser half: the same permission set -that gates a handler decides which controls the UI draws, so the two cannot drift. - -## Checking - -```ts -import { newGrants, newPermissionSet } from "@primandproper/authorization"; - -const grants = newGrants(servicePermissions, accountPermissions); - -grants.has("recipes.create"); // boolean, right now -grants.hasAny(["recipes.update", "recipes.delete"]); -grants.evaluate(["recipes.create", "billing.refund"]); // { "recipes.create": true, ... } -``` - -`evaluate` returns an entry for **every** requested permission, including the `false` ones — a -client distinguishing "denied" from "not asked" needs that to survive the round trip. It is the -shape a "what can I do" endpoint hands a UI. - -## No principal type - -There is deliberately no `Principal` here: enforcement needs _authority_, not identity. Deriving -authority from identity requires a store, which is exactly what drags I/O onto the check. -Consumers bridge their own session type with a `GrantsExtractor`, and that adapter is also where a -multi-scope model collapses: - -```ts -const extract: GrantsExtractor = (ctx) => { - const session = ctx.get("session"); - if (session === undefined) return undefined; // no authority determined -> denial - return newGrants( - session.servicePermissions, // may be undefined - session.accountPermissions[session.activeAccount], // absent key -> undefined - ); -}; -``` - -Sets that grant nothing are **dropped**, so "an administrator acting on a tenant they do not -belong to" carries one set instead of two and needs no branch anywhere. That case is the one most -likely to be forgotten, so it is handled by construction rather than by remembering. An -unpopulated `Grants` — `denyAll()` — denies everything, so a missing extractor or a failed -authentication is safe by default rather than by vigilance. - -## The empty-list hazard - -`hasAll()` with **no** permissions is vacuously `true`. That is the honest answer for a universal -quantifier, and it is a live hazard: a requirement list that accidentally resolved to zero -permissions would authorize everyone. The set algebra stays honest and each declaration site -guards, which means the sites answer the empty case differently **on purpose**: - -| site | empty list | -| -------------------------------------------- | --------------------------------------------- | -| `PermissionSet.hasAll()` / `Grants.hasAll()` | `true` — set algebra | -| server enforcement middleware | denies — far more likely a bug than an intent | -| a frozen requirements table | refuses to build at all | - -"Empty means allow" is therefore only ever safe with a list you wrote **literally**. Anything -derived from configuration, a database, or a lookup must be checked for emptiness first. The last -two rows describe the enforcement layer, which is not ported yet — see "Not ported" below — so -today that check is the caller's job. - -## Policy resolution (server only) - -A `Role` grants permissions and may inherit from other roles, transitively. `StaticPolicyResolver` -is the default and the one to reach for until something forces otherwise: no database, no -migrations, no configuration, and it resolves without I/O. - -```ts -import { StaticPolicyResolver } from "@primandproper/authorization"; - -const resolver = new StaticPolicyResolver([ - { name: "reader", permissions: ["recipes.read"] }, - { name: "author", permissions: ["recipes.create"], inherits: ["reader"] }, -]); - -const permissions = await resolver.permissionsForRoles(session.roles); // once, per session -``` - -Graduate to a stored policy only when roles themselves must become editable data — when an -operator has to define a role, or change what one grants, without shipping a release. Reassigning -_which_ roles a principal holds does not require it: assignments reference the consumer's own -users and tenants, so they belong to the consumer. **This package owns policy, not assignment.** - -Two rules the resolvers share, so backends stay interchangeable: - -- **Malformed policy is rejected, never partially applied.** `validateRoles` catches an unnamed - role, a duplicate, an undefined parent, self-inheritance, and inheritance cycles; the static - resolver runs it at construction, so a policy mistake fails at startup rather than as a puzzling - denial later. A policy with more than one cycle names the same one every run. -- **An unknown role contributes nothing rather than throwing.** A principal still assigned a role - the policy has since dropped loses that authority, not the ability to make requests. Use - `roles()` to detect that deliberately. - -`expandInheritance` is the reference semantics: any backend that expands some other way (in SQL, -say) owes a test asserting it agrees with this function. - -## The isomorphic split - -- `index.browser.ts` — permission sets, grants, and errors: the checking half. -- `index.node.ts` — the same, plus policy resolution. - -Resolution is absent from the browser rather than stubbed. It may do I/O and belongs to the -server, which hands the browser the resolved set; a browser that could resolve policy would be a -browser that _holds_ policy. `grantsFromJSON` is the hydration seam, and it is deliberately -tolerant: a payload whose shape drifted costs its holder authority rather than throwing mid-render. - -## Turning it off - -`allowAll()` is the escape hatch, and it is deliberately a function call at a call site rather -than a configuration flag: turning authorization off should be visible in a diff, not reachable by -setting an environment variable in production. - -## Errors - -`PermissionDeniedError` says exactly `"permission denied"` and nothing else. Which permission was -missing goes to `missing`, and from there to a span or a log, and stops there — naming it in a -response discloses the permission taxonomy to a caller who just failed to authorize. Serialize -`message` and nothing more. Wrapping with `@primandproper/errors`' `wrap` preserves the code, so -adding context at a boundary does not turn a denial into a 500. - -## Not ported - -- **Server enforcement** — the middleware that denies an empty requirement list, and the frozen - requirements table that refuses to build one. TypeScript has a real opportunity here that Go - lacked: with hono/express/fastify the route table is enumerable at startup, so a boot-time check - that every registered route either declares permissions or is explicitly marked public is - achievable. -- **A database-backed resolver**, and the cached-resolver wrapper `PolicyInvalidator` exists for. -- **A `config.ts` provider factory** — resolvers are constructed directly for now. - -Tracked in [#9](https://github.com/primandproper/platform-ts/issues/9). diff --git a/packages/authorization/package.json b/packages/authorization/package.json deleted file mode 100644 index c2b3a1f..0000000 --- a/packages/authorization/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@primandproper/authorization", - "version": "0.0.0", - "type": "module", - "description": "Isomorphic authorization: synchronous permission checks everywhere, pluggable policy resolution on the server.", - "license": "AGPL-3.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/primandproper/platform-ts.git", - "directory": "packages/authorization" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "browser": { - "types": "./dist/index.browser.d.ts", - "default": "./dist/index.browser.js" - }, - "node": { - "types": "./dist/index.node.d.ts", - "default": "./dist/index.node.js" - }, - "default": { - "types": "./dist/index.node.d.ts", - "default": "./dist/index.node.js" - } - } - }, - "types": "./dist/index.node.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "eslint .", - "clean": "rm -rf dist .turbo" - }, - "dependencies": { - "@primandproper/errors": "workspace:*", - "@primandproper/observability": "workspace:*", - "zod": "3.25.76" - } -} diff --git a/packages/authorization/src/errors.test.ts b/packages/authorization/src/errors.test.ts deleted file mode 100644 index 51da2a9..0000000 --- a/packages/authorization/src/errors.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { isPlatformError, wrap } from "@primandproper/errors"; -import { describe, expect, it } from "vitest"; - -import { - InvalidPolicyError, - isInvalidPolicy, - isPermissionDenied, - PermissionDeniedError, - PERMISSION_DENIED_CODE, - POLICY_INVALID_CODE, -} from "./errors.js"; - -describe("PermissionDeniedError", () => { - it("says only 'permission denied', whatever was missing", () => { - // Naming the permission in a response discloses the taxonomy to a caller who just failed to - // authorize, so the message is a constant and the detail rides alongside. - const err = new PermissionDeniedError(["billing.refund"]); - expect(err.message).toBe("permission denied"); - expect(err.message).not.toContain("billing.refund"); - expect(err.missing).toEqual(["billing.refund"]); - }); - - it("carries the shared code and is recognised by the guard", () => { - const err = new PermissionDeniedError(); - expect(err.code).toBe(PERMISSION_DENIED_CODE); - expect(isPermissionDenied(err)).toBe(true); - expect(isPlatformError(err, PERMISSION_DENIED_CODE)).toBe(true); - }); - - it("defaults to no missing permissions and copies the list it is given", () => { - expect(new PermissionDeniedError().missing).toEqual([]); - - const missing = ["a"]; - const err = new PermissionDeniedError(missing); - missing.push("b"); - expect(err.missing).toEqual(["a"]); - }); - - it("is still recognised once wrapped for context", () => { - // `wrap` re-raises a PlatformError under the same code, so adding context at a boundary does - // not turn a denial into a 500. The `missing` list does not survive the wrap — it lives on - // the original, reachable through `cause`. - const original = new PermissionDeniedError(["users.delete"]); - const wrapped = wrap("deleting user", original); - - expect(isPermissionDenied(wrapped)).toBe(true); - expect(wrapped.message).toBe("deleting user: permission denied"); - expect(wrapped).not.toBeInstanceOf(PermissionDeniedError); - expect((wrapped.cause as PermissionDeniedError).missing).toEqual(["users.delete"]); - }); - - it("keeps a cause when given one", () => { - const cause = new Error("upstream"); - expect(new PermissionDeniedError([], { cause }).cause).toBe(cause); - }); - - it("is not confused for a policy error", () => { - expect(isInvalidPolicy(new PermissionDeniedError())).toBe(false); - }); -}); - -describe("InvalidPolicyError", () => { - it("codes the specific problem under the shared prefix", () => { - const err = new InvalidPolicyError("duplicate-role", "duplicate role name: reader"); - expect(err.code).toBe(`${POLICY_INVALID_CODE}/duplicate-role`); - expect(err.problem).toBe("duplicate-role"); - expect(err.message).toBe("duplicate role name: reader"); - }); - - it("is matched by the guard, with or without a specific problem", () => { - const err = new InvalidPolicyError("inheritance-cycle", "a -> b -> a"); - expect(isInvalidPolicy(err)).toBe(true); - expect(isInvalidPolicy(err, "inheritance-cycle")).toBe(true); - expect(isInvalidPolicy(err, "duplicate-role")).toBe(false); - }); - - it("does not match a non-platform error or an unrelated one", () => { - expect(isInvalidPolicy(new Error("nope"))).toBe(false); - expect(isInvalidPolicy(undefined)).toBe(false); - expect(isPermissionDenied(new InvalidPolicyError("empty-role-name", "x"))).toBe( - false, - ); - }); -}); diff --git a/packages/authorization/src/errors.ts b/packages/authorization/src/errors.ts deleted file mode 100644 index 235d843..0000000 --- a/packages/authorization/src/errors.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { isPlatformError, PlatformError } from "@primandproper/errors"; - -/** The `code` every {@link PermissionDeniedError} carries. */ -export const PERMISSION_DENIED_CODE = "authorization/permission-denied"; - -/** The `code` prefix shared by every malformed-policy error. */ -export const POLICY_INVALID_CODE = "authorization/policy-invalid"; - -/** - * The requester lacks the authority to perform the action. - * - * The message a client sees is the constant `"permission denied"`. Which permission was missing - * goes to `missing`, and from there to the span and the log, and stops there — naming it in a - * response discloses the permission taxonomy to a caller who just failed to authorize. Anything - * that serializes this error to a client should send `message` and nothing else. - */ -export class PermissionDeniedError extends PlatformError { - /** The permissions the principal did not hold. Diagnostic only — never put this in a response body. */ - readonly missing: readonly string[]; - - constructor(missing: readonly string[] = [], options?: ErrorOptions) { - super(PERMISSION_DENIED_CODE, "permission denied", options); - this.name = "PermissionDeniedError"; - this.missing = [...missing]; - } -} - -/** True when `err` is a {@link PermissionDeniedError}, including one wrapped by `@primandproper/errors`. */ -export function isPermissionDenied(err: unknown): boolean { - return isPlatformError(err, PERMISSION_DENIED_CODE); -} - -/** Why a policy was rejected. Each value is also the suffix of the thrown error's `code`. */ -export type PolicyProblem = - | "empty-role-name" - | "duplicate-role" - | "unknown-parent-role" - | "self-inheritance" - | "inheritance-cycle"; - -/** - * A policy is malformed and was rejected rather than partially applied. - * - * Both the validator and every resolver throw this, so a policy rejected in one backend is - * rejected in the others — a code-side policy cannot quietly drift from a stored one. - */ -export class InvalidPolicyError extends PlatformError { - /** Which rule the policy broke, for callers that branch rather than just report. */ - readonly problem: PolicyProblem; - - constructor(problem: PolicyProblem, message: string, options?: ErrorOptions) { - super(`${POLICY_INVALID_CODE}/${problem}`, message, options); - this.name = "InvalidPolicyError"; - this.problem = problem; - } -} - -/** True when `err` is an {@link InvalidPolicyError}; narrows to a specific `problem` when given. */ -export function isInvalidPolicy(err: unknown, problem?: PolicyProblem): boolean { - if (!isPlatformError(err)) { - return false; - } - return problem === undefined - ? err.code.startsWith(`${POLICY_INVALID_CODE}/`) - : err.code === `${POLICY_INVALID_CODE}/${problem}`; -} diff --git a/packages/authorization/src/grants.test.ts b/packages/authorization/src/grants.test.ts deleted file mode 100644 index 9a6fef7..0000000 --- a/packages/authorization/src/grants.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - allowAll, - denyAll, - grantsFromJSON, - newGrants, - type GrantsExtractor, -} from "./grants.js"; -import { emptyPermissionSet, newPermissionSet } from "./permission.js"; - -const service = newPermissionSet("service.admin"); -const account = newPermissionSet("recipes.create", "recipes.read"); - -describe("Grants", () => { - it("ORs the sets it was given", () => { - const grants = newGrants(service, account); - expect(grants.has("service.admin")).toBe(true); - expect(grants.has("recipes.create")).toBe(true); - expect(grants.has("nothing.here")).toBe(false); - }); - - it("denies everything when unpopulated", () => { - expect(denyAll().isEmpty()).toBe(true); - expect(denyAll().has("anything")).toBe(false); - expect(newGrants().has("anything")).toBe(false); - }); - - it("drops sets that grant nothing, so the awkward case needs no branch", () => { - // "Administrator acting on a tenant they are not a member of" arrives as one set, not two. - const grants = newGrants(service, emptyPermissionSet, undefined); - expect(grants.has("service.admin")).toBe(true); - expect(grants.isEmpty()).toBe(false); - - expect(newGrants(emptyPermissionSet, undefined).isEmpty()).toBe(true); - }); - - it("allows everything under allowAll, and says so", () => { - const grants = allowAll(); - expect(grants.has("anything.at.all")).toBe(true); - expect(grants.hasAll(["a", "b"])).toBe(true); - expect(grants.isAllowAll()).toBe(true); - expect(grants.isEmpty()).toBe(false); - expect(denyAll().isAllowAll()).toBe(false); - }); - - it("is vacuously true for hasAll with no permissions — the documented hazard", () => { - expect(denyAll().hasAll([])).toBe(true); - }); - - it("requires every permission for hasAll and any one for hasAny", () => { - const grants = newGrants(account); - expect(grants.hasAll(["recipes.create", "recipes.read"])).toBe(true); - expect(grants.hasAll(["recipes.create", "recipes.delete"])).toBe(false); - expect(grants.hasAny(["recipes.delete", "recipes.read"])).toBe(true); - expect(grants.hasAny(["recipes.delete"])).toBe(false); - }); - - it("evaluates every requested permission, including the denials", () => { - // A client distinguishing "denied" from "not asked" needs the false entries to survive. - expect(newGrants(account).evaluate(["recipes.read", "recipes.delete"])).toEqual({ - "recipes.read": true, - "recipes.delete": false, - }); - }); - - it("evaluates to an empty record when asked about nothing", () => { - expect(newGrants(account).evaluate([])).toEqual({}); - }); - - it("reports what is missing, in the order asked", () => { - expect( - newGrants(account).missing(["recipes.delete", "recipes.read", "billing"]), - ).toEqual(["recipes.delete", "billing"]); - expect(newGrants(account).missing(["recipes.read"])).toEqual([]); - }); -}); - -describe("GrantsExtractor", () => { - it("bridges a consumer session, collapsing scopes into one OR'd authority", () => { - interface Session { - servicePermissions?: ReturnType; - accountPermissions: Record>; - activeAccount: string; - } - const extract: GrantsExtractor = (session) => { - if (session === undefined) { - return undefined; - } - return newGrants( - session.servicePermissions, - session.accountPermissions[session.activeAccount], - ); - }; - - const grants = extract({ - accountPermissions: { acct_1: account }, - activeAccount: "acct_1", - }); - expect(grants?.has("recipes.create")).toBe(true); - - // An account the principal is not a member of: an absent key, not a special case. - const outsider = extract({ - servicePermissions: service, - accountPermissions: { acct_1: account }, - activeAccount: "acct_2", - }); - expect(outsider?.has("service.admin")).toBe(true); - expect(outsider?.has("recipes.create")).toBe(false); - - // No authority could be determined — a denial, and never an error. - expect(extract(undefined)).toBeUndefined(); - }); -}); - -describe("grantsFromJSON", () => { - it("hydrates every scope a session carries", () => { - const grants = grantsFromJSON(["service.admin"], ["recipes.create"]); - expect(grants.has("service.admin")).toBe(true); - expect(grants.has("recipes.create")).toBe(true); - }); - - it("costs a malformed payload its authority rather than throwing", () => { - expect(grantsFromJSON(undefined, "not-an-array", { nope: true }).isEmpty()).toBe( - true, - ); - }); - - it("survives a partially malformed payload with the readable scopes intact", () => { - const grants = grantsFromJSON(["service.admin"], null); - expect(grants.has("service.admin")).toBe(true); - }); -}); diff --git a/packages/authorization/src/grants.ts b/packages/authorization/src/grants.ts deleted file mode 100644 index 2a3cffa..0000000 --- a/packages/authorization/src/grants.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { PermissionSet, permissionSetFromJSON, type Permission } from "./permission.js"; - -/** - * A principal's effective authority for a single request: one or more granted permission sets, - * OR'd together. - * - * It holds an array of sets rather than a materialized union deliberately. Merging a service-wide - * set and an account-scoped set of a few hundred permissions each would allocate a set of their - * combined size on every request; OR-ing at lookup allocates nothing. - * - * {@link denyAll} — which is what an unpopulated `Grants` is — denies everything, so a missing - * extractor, a failed authentication, or a field nobody set is safe by construction rather than by - * remembering to check. - */ -export class Grants { - readonly #sets: readonly PermissionSet[]; - readonly #all: boolean; - - /** @internal Use {@link newGrants}, {@link allowAll}, or {@link denyAll}. */ - private constructor(sets: readonly PermissionSet[], all: boolean) { - this.#sets = sets; - this.#all = all; - } - - /** @internal */ - static build(sets: readonly PermissionSet[], all: boolean): Grants { - return new Grants(sets, all); - } - - /** Reports whether any of the granted sets contains `perm`. */ - has(perm: Permission): boolean { - if (this.#all) { - return true; - } - for (const set of this.#sets) { - if (set.has(perm)) { - return true; - } - } - return false; - } - - /** - * Reports whether every permission in `perms` is granted. - * - * Calling it with no permissions is vacuously `true`, so a list that reached zero length by - * accident authorizes everyone. {@link PermissionSet.hasAll} carries the full matrix of which - * declaration sites guard the empty case and how — read it before calling this from anything - * that decides access. Code deciding access from a *derived* list must reject the empty case - * itself before calling this, since the enforcement layer that would otherwise do it is not - * ported yet. - */ - hasAll(perms: Iterable): boolean { - for (const p of perms) { - if (!this.has(p)) { - return false; - } - } - return true; - } - - /** Reports whether any permission in `perms` is granted. */ - hasAny(perms: Iterable): boolean { - for (const p of perms) { - if (this.has(p)) { - return true; - } - } - return false; - } - - /** - * Reports the outcome for each permission in `perms`. - * - * This is the shape a "what can I do" endpoint needs in order to tell a client which controls to - * render. The returned record always has an entry for every requested permission, **including - * the `false` ones** — a caller distinguishing "denied" from "not asked" needs that distinction - * to survive the round trip. - */ - evaluate(perms: Iterable): Record { - const out: Record = {}; - for (const p of perms) { - out[p] = this.has(p); - } - return out; - } - - /** The permissions missing from `perms`, in the order given. Empty when {@link hasAll} would be `true`. */ - missing(perms: Iterable): Permission[] { - const out: Permission[] = []; - for (const p of perms) { - if (!this.has(p)) { - out.push(p); - } - } - return out; - } - - /** Reports whether these grants permit nothing at all. */ - isEmpty(): boolean { - return !this.#all && this.#sets.length === 0; - } - - /** Reports whether these grants came from {@link allowAll}. Enforcement logs it; nothing else should branch on it. */ - isAllowAll(): boolean { - return this.#all; - } -} - -/** - * Builds {@link Grants} from one or more permission sets. Sets that grant nothing are dropped. - * - * Dropping them is what makes the awkward case structural rather than conditional: a service - * administrator acting on a tenant they are not a member of simply carries one set instead of two. - * Callers do not check for it, and cannot forget to. That case is the one most likely to be - * forgotten, which is why it is handled by construction here rather than by a branch out there. - */ -export function newGrants(...sets: readonly (PermissionSet | undefined)[]): Grants { - const kept = sets.filter((s): s is PermissionSet => s !== undefined && !s.isEmpty()); - return Grants.build(kept, false); -} - -/** - * Grants that permit everything. - * - * It exists for tests and local development, and it is deliberately a function call at a call site - * rather than a configurable provider: turning authorization off should be visible in a diff, not - * reachable by setting an environment variable in production. - */ -export function allowAll(): Grants { - return Grants.build([], true); -} - -/** Grants that permit nothing. The default, named. */ -export function denyAll(): Grants { - return Grants.build([], false); -} - -/** - * Pulls a principal's authority out of whatever a consumer's request context happens to be. - * - * It is a function rather than an interface so this package never needs to know how a consumer - * represents a session. The consumer writes the adapter over whatever its authentication layer - * produced, and that adapter is where a multi-scope model collapses into the flat "these sets, - * OR'd" this package understands: - * - * ```ts - * const extract: GrantsExtractor = (ctx) => { - * const session = ctx.get("session"); - * if (session === undefined) return undefined; - * return newGrants( - * session.servicePermissions, // may be undefined - * session.accountPermissions[session.activeAccount], // absent key -> undefined - * ); - * }; - * ``` - * - * Returning `undefined` means "no authority could be determined", which every enforcement path - * treats as a denial — not as an error, and never as a pass. - */ -export type GrantsExtractor = (ctx: Ctx) => Grants | undefined; - -/** - * Hydrates {@link Grants} from decoded session JSON — the browser's entry point into this package. - * - * Each value is passed through {@link permissionSetFromJSON}, so a payload whose shape drifted - * costs its holder authority rather than throwing mid-render. Pass every scope the session carries; - * the ones that grant nothing drop out. - */ -export function grantsFromJSON(...values: readonly unknown[]): Grants { - return newGrants(...values.map(permissionSetFromJSON)); -} diff --git a/packages/authorization/src/index.browser.ts b/packages/authorization/src/index.browser.ts deleted file mode 100644 index c061bfb..0000000 --- a/packages/authorization/src/index.browser.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Browser entry: the checking half — permission sets, grants, and the errors they raise. - * - * This is the half that decides which controls a UI renders, and it is deliberately the *same* - * code the server checks with, so the two cannot drift. A session payload hydrates it directly - * through {@link grantsFromJSON}; every check is synchronous, so it is usable in a render path - * without a suspense boundary or a loading state. - * - * Policy resolution is absent here rather than stubbed: resolving role names to permissions may - * do I/O and belongs to the server, which then hands the browser the resolved set. Reaching for - * `StaticPolicyResolver` in browser-resolved code is a type error, which is the intended - * feedback — a browser that could resolve policy would be a browser that holds it. - */ -export * from "./errors.js"; -export * from "./grants.js"; -export * from "./permission.js"; diff --git a/packages/authorization/src/index.node.ts b/packages/authorization/src/index.node.ts deleted file mode 100644 index 78c6d31..0000000 --- a/packages/authorization/src/index.node.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Node entry: the checking half the browser also gets, plus policy resolution. - * - * The asymmetry is the design. Checking is synchronous, infallible, and identical on both sides; - * resolving role names to permissions may touch a database and stays on the server. A caller - * resolves once when it builds a session and checks many times per request against the resulting - * {@link Grants}. - */ -export * from "./errors.js"; -export * from "./grants.js"; -export * from "./permission.js"; -export * from "./policy.js"; -export { StaticPolicyResolver } from "./providers/static.js"; diff --git a/packages/authorization/src/permission.test.ts b/packages/authorization/src/permission.test.ts deleted file mode 100644 index af766e4..0000000 --- a/packages/authorization/src/permission.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - emptyPermissionSet, - newPermissionSet, - PermissionSet, - permissionSetFromJSON, -} from "./permission.js"; - -describe("PermissionSet", () => { - it("holds the permissions it was built with", () => { - const set = newPermissionSet("read", "write"); - expect(set.has("read")).toBe(true); - expect(set.has("write")).toBe(true); - expect(set.has("delete")).toBe(false); - expect(set.size).toBe(2); - }); - - it("collapses duplicates and drops empty strings", () => { - const set = new PermissionSet(["read", "read", "", "write"]); - expect(set.size).toBe(2); - // An empty permission can never be held, so an empty check can never accidentally pass. - expect(set.has("")).toBe(false); - }); - - it("copies its input, so a later mutation cannot edit the set", () => { - const source = ["read"]; - const set = new PermissionSet(source); - source.push("write"); - expect(set.has("write")).toBe(false); - }); - - it("grants nothing when empty", () => { - expect(emptyPermissionSet.isEmpty()).toBe(true); - expect(emptyPermissionSet.has("read")).toBe(false); - expect(emptyPermissionSet.size).toBe(0); - }); - - it("is vacuously true for hasAll with no permissions — the documented hazard", () => { - // Set algebra's honest answer, and the reason a derived requirement list must be checked for - // emptiness before it reaches here. - expect(emptyPermissionSet.hasAll([])).toBe(true); - expect(newPermissionSet("read").hasAll([])).toBe(true); - }); - - it("is false for hasAny with no permissions — there is no witness", () => { - expect(newPermissionSet("read").hasAny([])).toBe(false); - }); - - it("requires every permission for hasAll and any one for hasAny", () => { - const set = newPermissionSet("read", "write"); - expect(set.hasAll(["read", "write"])).toBe(true); - expect(set.hasAll(["read", "delete"])).toBe(false); - expect(set.hasAny(["delete", "write"])).toBe(true); - expect(set.hasAny(["delete"])).toBe(false); - }); - - it("orders values deterministically, so serialized forms are stable", () => { - expect(newPermissionSet("write", "admin", "read").values()).toEqual([ - "admin", - "read", - "write", - ]); - expect([...newPermissionSet("b", "a")]).toEqual(["a", "b"]); - }); - - it("unions without mutating either operand", () => { - const a = newPermissionSet("read"); - const b = newPermissionSet("write"); - - const union = a.union(b); - - expect(union.values()).toEqual(["read", "write"]); - expect(a.size).toBe(1); - expect(b.size).toBe(1); - }); - - it("unions several sets at once", () => { - expect( - newPermissionSet("a").union(newPermissionSet("b"), newPermissionSet("c")).values(), - ).toEqual(["a", "b", "c"]); - }); - - it("treats the empty set as a subset of everything", () => { - expect(emptyPermissionSet.isSubsetOf(newPermissionSet("read"))).toBe(true); - expect(newPermissionSet("read").isSubsetOf(emptyPermissionSet)).toBe(false); - }); - - it("compares by contents, not identity", () => { - expect(newPermissionSet("a", "b").equals(newPermissionSet("b", "a"))).toBe(true); - expect(newPermissionSet("a").equals(newPermissionSet("a", "b"))).toBe(false); - }); - - it("summarises rather than lists in toString, so telemetry cannot leak the taxonomy", () => { - const set = newPermissionSet("billing.refund", "users.delete"); - expect(set.toString()).toBe("PermissionSet(n=2)"); - expect(set.toString()).not.toContain("billing.refund"); - }); -}); - -describe("PermissionSet JSON round trip", () => { - it("encodes as a sorted array through JSON.stringify", () => { - expect(JSON.stringify(newPermissionSet("write", "read"))).toBe('["read","write"]'); - }); - - it("hydrates back to an equal set", () => { - const original = newPermissionSet("read", "write"); - const hydrated = permissionSetFromJSON(JSON.parse(JSON.stringify(original))); - expect(hydrated.equals(original)).toBe(true); - }); - - it("costs a malformed payload its authority rather than throwing", () => { - // Failing closed matters more than failing loudly here: throwing inside a render is the - // worst available outcome, and an unreadable payload should grant nothing. - for (const malformed of [undefined, null, 42, "read", { read: true }]) { - expect(permissionSetFromJSON(malformed).isEmpty()).toBe(true); - } - }); - - it("keeps the string entries of a mixed array and drops the rest", () => { - expect(permissionSetFromJSON(["read", 7, null, "write"]).values()).toEqual([ - "read", - "write", - ]); - }); -}); diff --git a/packages/authorization/src/permission.ts b/packages/authorization/src/permission.ts deleted file mode 100644 index 1a93768..0000000 --- a/packages/authorization/src/permission.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Names an action a principal may be authorized to perform. - * - * It is a bare string so consumers declare their own vocabulary as ordinary constants: - * - * ```ts - * export const CreateRecipes: Permission = "create.recipes"; - * ``` - * - * A consumer with an existing permission union adopts this one with a type alias - * (`type Permission = import("@primandproper/authorization").Permission`), which leaves every - * existing constant, object key, and `switch` compiling unchanged. - */ -export type Permission = string; - -/** - * An immutable set of permissions. - * - * Every check is synchronous, allocation-free, and cannot fail — see the package README for why - * that is the property the whole design exists to protect. An empty set grants nothing, which is - * load-bearing rather than defensive: a principal with no membership in some scope is represented - * by an empty set rather than an absent entry, so "no grants here" needs no special case at any - * call site. - */ -export class PermissionSet { - readonly #perms: ReadonlySet; - - /** - * Builds a set containing `perms`. Duplicates collapse and empty strings are dropped. The set - * copies its input, so mutating the caller's array afterwards cannot change it. - */ - constructor(perms: Iterable = []) { - const set = new Set(); - for (const p of perms) { - if (p !== "") { - set.add(p); - } - } - this.#perms = set; - } - - /** Reports whether `perm` is in the set. */ - has(perm: Permission): boolean { - return perm !== "" && this.#perms.has(perm); - } - - /** - * Reports whether every permission in `perms` is in the set. - * - * **`hasAll()` with no permissions is vacuously `true`.** That is the mathematically honest - * answer for a universal quantifier, and it is a live hazard: a requirement list that - * accidentally resolved to zero permissions would authorize everyone. Set algebra wins here and - * the guard belongs at each declaration site, so the three places a permission list is declared - * answer the empty case differently on purpose: - * - * | site | empty list | - * | --- | --- | - * | `PermissionSet.hasAll()` / `Grants.hasAll()` | `true` — set algebra | - * | server enforcement middleware | denies — an empty list is far more likely a bug | - * | a frozen requirements table | refuses to build at all | - * - * "Empty means allow" is therefore only ever safe with a list you wrote literally. Anything - * derived from configuration, a database, or a lookup must be checked for emptiness before it - * reaches here. - * - * **The last two rows describe an enforcement layer this package does not ship yet** — see - * "Not ported" in the README. Until it lands, guarding a derived list is the caller's job, and - * this is the hazard to guard against. - */ - hasAll(perms: Iterable): boolean { - for (const p of perms) { - if (!this.has(p)) { - return false; - } - } - return true; - } - - /** Reports whether any permission in `perms` is in the set. With no permissions: `false` — there is no witness. */ - hasAny(perms: Iterable): boolean { - for (const p of perms) { - if (this.has(p)) { - return true; - } - } - return false; - } - - /** The number of permissions in the set. */ - get size(): number { - return this.#perms.size; - } - - /** Reports whether the set grants nothing. */ - isEmpty(): boolean { - return this.#perms.size === 0; - } - - /** - * The set's permissions in sorted order. The order is deterministic so that encodings, golden - * files, and equality checks over serialized forms are stable across runs. - */ - values(): Permission[] { - return [...this.#perms].sort(); - } - - /** Iterates the set in sorted order. */ - [Symbol.iterator](): IterableIterator { - return this.values()[Symbol.iterator](); - } - - /** A new set containing everything in this set and in `others`. */ - union(...others: readonly PermissionSet[]): PermissionSet { - const merged = new Set(this.#perms); - for (const other of others) { - for (const p of other.#perms) { - merged.add(p); - } - } - return new PermissionSet(merged); - } - - /** Reports whether every permission in this set is also in `other`. The empty set is a subset of everything. */ - isSubsetOf(other: PermissionSet): boolean { - for (const p of this.#perms) { - if (!other.has(p)) { - return false; - } - } - return true; - } - - /** Reports whether this set and `other` contain exactly the same permissions. */ - equals(other: PermissionSet): boolean { - return this.size === other.size && this.isSubsetOf(other); - } - - /** - * Encodes the set as a sorted array of strings — the wire form a server hands a browser so a - * session payload can hydrate one directly. `JSON.stringify(set)` picks this up automatically. - */ - toJSON(): Permission[] { - return this.values(); - } - - /** - * Deliberately a summary rather than a listing. A set can hold hundreds of permissions and this - * type ends up attached to logs and spans; dumping the whole policy into telemetry on every - * request would be both noisy and a disclosure. - */ - toString(): string { - return `PermissionSet(n=${String(this.size)})`; - } -} - -/** A set that grants nothing. Shared singleton — every empty set behaves identically. */ -export const emptyPermissionSet = new PermissionSet(); - -/** Builds a {@link PermissionSet} from a variadic list, for call sites that read better that way. */ -export function newPermissionSet(...perms: readonly Permission[]): PermissionSet { - return new PermissionSet(perms); -} - -/** - * Rebuilds a {@link PermissionSet} from a decoded session payload — the browser half of the seam - * that {@link PermissionSet.toJSON} opens on the server. - * - * It is deliberately tolerant: anything that is not an array of strings hydrates to the empty set - * rather than throwing. A malformed payload should cost its holder authority, not the ability to - * render the page — and the alternative, throwing inside a React render, fails open in the sense - * that matters least and hardest. - */ -export function permissionSetFromJSON(value: unknown): PermissionSet { - if (!Array.isArray(value)) { - return emptyPermissionSet; - } - return new PermissionSet(value.filter((v): v is string => typeof v === "string")); -} diff --git a/packages/authorization/src/policy.test.ts b/packages/authorization/src/policy.test.ts deleted file mode 100644 index adafdf8..0000000 --- a/packages/authorization/src/policy.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { isInvalidPolicy } from "./errors.js"; -import { emptyPermissionSet, PermissionSet } from "./permission.js"; -import { - expandInheritance, - isPolicyInvalidator, - validateRoles, - type PolicyInvalidator, - type PolicyResolver, - type Role, -} from "./policy.js"; - -const roles: Role[] = [ - { name: "reader", permissions: ["recipes.read"] }, - { name: "author", permissions: ["recipes.create"], inherits: ["reader"] }, - { name: "editor", permissions: ["recipes.update"], inherits: ["author"] }, -]; - -/** Asserts `fn` throws an InvalidPolicyError with the given `problem`. */ -function expectProblem(fn: () => unknown, problem: string): void { - try { - fn(); - } catch (err) { - expect(isInvalidPolicy(err, problem as never)).toBe(true); - return; - } - expect.unreachable(`expected a ${problem} policy error`); -} - -describe("validateRoles", () => { - it("accepts a well-formed policy", () => { - expect(() => { - validateRoles(roles); - }).not.toThrow(); - }); - - it("accepts an empty policy", () => { - expect(() => { - validateRoles([]); - }).not.toThrow(); - }); - - it("rejects an unnamed role", () => { - expectProblem(() => { - validateRoles([{ name: "", permissions: [] }]); - }, "empty-role-name"); - }); - - it("rejects a duplicate role name", () => { - expectProblem(() => { - validateRoles([ - { name: "reader", permissions: ["a"] }, - { name: "reader", permissions: ["b"] }, - ]); - }, "duplicate-role"); - }); - - it("rejects inheritance from a role the policy does not define", () => { - expectProblem(() => { - validateRoles([{ name: "author", permissions: [], inherits: ["ghost"] }]); - }, "unknown-parent-role"); - }); - - it("rejects a role inheriting from itself", () => { - expectProblem(() => { - validateRoles([{ name: "loop", permissions: [], inherits: ["loop"] }]); - }, "self-inheritance"); - }); - - it("rejects an inheritance cycle", () => { - expectProblem(() => { - validateRoles([ - { name: "a", permissions: [], inherits: ["b"] }, - { name: "b", permissions: [], inherits: ["c"] }, - { name: "c", permissions: [], inherits: ["a"] }, - ]); - }, "inheritance-cycle"); - }); - - it("names the same cycle on every run, so the error is actionable", () => { - const cyclic: Role[] = [ - { name: "x", permissions: [], inherits: ["y"] }, - { name: "y", permissions: [], inherits: ["x"] }, - { name: "p", permissions: [], inherits: ["q"] }, - { name: "q", permissions: [], inherits: ["p"] }, - ]; - const messages = new Set(); - for (let i = 0; i < 5; i += 1) { - try { - validateRoles(cyclic); - } catch (err) { - messages.add((err as Error).message); - } - } - expect(messages.size).toBe(1); - }); - - it("accepts a diamond, which is not a cycle", () => { - expect(() => { - validateRoles([ - { name: "base", permissions: ["read"] }, - { name: "left", permissions: ["l"], inherits: ["base"] }, - { name: "right", permissions: ["r"], inherits: ["base"] }, - { name: "top", permissions: [], inherits: ["left", "right"] }, - ]); - }).not.toThrow(); - }); -}); - -describe("expandInheritance", () => { - it("applies inheritance transitively", () => { - const expanded = expandInheritance(roles); - - expect(expanded.get("reader")?.values()).toEqual(["recipes.read"]); - expect(expanded.get("author")?.values()).toEqual(["recipes.create", "recipes.read"]); - // editor -> author -> reader, all three levels. - expect(expanded.get("editor")?.values()).toEqual([ - "recipes.create", - "recipes.read", - "recipes.update", - ]); - }); - - it("unions several parents rather than ordering them", () => { - const expanded = expandInheritance([ - { name: "base", permissions: ["read"] }, - { name: "left", permissions: ["l"], inherits: ["base"] }, - { name: "right", permissions: ["r"], inherits: ["base"] }, - { name: "top", permissions: ["t"], inherits: ["left", "right"] }, - ]); - - expect(expanded.get("top")?.values()).toEqual(["l", "r", "read", "t"]); - }); - - it("validates first, so a malformed policy yields no partial expansion", () => { - expect(() => - expandInheritance([{ name: "a", permissions: ["x"], inherits: ["ghost"] }]), - ).toThrow(); - }); - - it("leaves the caller's roles untouched", () => { - const input: Role[] = [{ name: "reader", permissions: ["recipes.read"] }]; - expandInheritance(input); - expect(input[0]?.permissions).toEqual(["recipes.read"]); - }); -}); - -describe("isPolicyInvalidator", () => { - const base: PolicyResolver = { - permissionsForRoles: () => Promise.resolve(emptyPermissionSet), - roles: () => Promise.resolve([]), - }; - - it("is false for a resolver that only resolves", () => { - expect(isPolicyInvalidator(base)).toBe(false); - }); - - it("is true once both invalidation methods are present", () => { - const caching = { - ...base, - invalidate: () => Promise.resolve(), - invalidateAll: () => Promise.resolve(), - }; - expect(isPolicyInvalidator(caching)).toBe(true); - }); - - it("is false when only half the contract is implemented", () => { - const halfway: PolicyResolver & Partial = { - ...base, - invalidateAll: () => Promise.resolve(), - }; - expect(isPolicyInvalidator(halfway)).toBe(false); - }); -}); - -describe("PermissionSet as the resolver's currency", () => { - it("is what expansion produces, so backends are interchangeable by construction", () => { - for (const set of expandInheritance(roles).values()) { - expect(set).toBeInstanceOf(PermissionSet); - } - }); -}); diff --git a/packages/authorization/src/policy.ts b/packages/authorization/src/policy.ts deleted file mode 100644 index 083476f..0000000 --- a/packages/authorization/src/policy.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { InvalidPolicyError } from "./errors.js"; -import { PermissionSet, type Permission } from "./permission.js"; - -/** - * A named grant of permissions, optionally inheriting from other roles. - * - * The same `Role[]` value seeds every backend, which is what makes them interchangeable — and it - * is the fix for the failure mode where a code-side role table and a stored seed drift apart - * because nothing ever checks them against each other. - */ -export interface Role { - /** Identifies the role. It is the string a principal's role assignments refer to, and it must be unique within a policy. */ - name: string; - /** Human-facing documentation, surfaced by {@link PolicyResolver.roles} for admin tooling. No effect on resolution. */ - description?: string; - /** The permissions this role grants directly, before inheritance is applied. */ - permissions: readonly Permission[]; - /** - * The roles this role inherits from. Inheritance is transitive: a role receives the permissions - * of its parents, its parents' parents, and so on. It is not an ordering — a role may inherit - * from several, and the result is their union. - */ - inherits?: readonly string[]; -} - -/** - * Answers "what can these roles do". - * - * This is the only asynchronous, fallible, pluggable part of the package, and that is the whole - * design: resolving policy may hit a database; checking a permission never does. Callers resolve - * once when they build a session and check many times per request against the resulting - * {@link Grants}. - * - * Implementations must be safe for concurrent use. - */ -export interface PolicyResolver { - /** - * The effective permissions of the named roles with inheritance expanded — the union of what - * each role grants. - * - * Unknown role names contribute nothing rather than throwing: a policy that no longer defines a - * role a principal is still assigned must fail closed, not fail the request. Use {@link roles} - * to detect that case deliberately. Calling it with no roles returns an empty set. - */ - permissionsForRoles(roles: readonly string[]): Promise; - /** Every role the policy defines, for introspection and admin tooling. The order is unspecified. */ - roles(): Promise; -} - -/** - * The optional half of a {@link PolicyResolver} that memoizes. - * - * It is declared apart from `PolicyResolver` so a caller handed a resolver it did not construct - * can reach invalidation without knowing which concrete type it was given — whether a cache sits - * in the chain is an assembly decision, not a call-site one: - * - * ```ts - * if (isPolicyInvalidator(resolver)) await resolver.invalidateAll(); - * ``` - * - * The process that edits policy is exactly the one that needs this, and the one least likely to - * know how its resolver was assembled. - */ -export interface PolicyInvalidator { - /** Drops the memoized resolution for an exact set of roles. */ - invalidate(roles: readonly string[]): Promise; - /** Makes every resolution this instance memoized unreachable. Process-local: other replicas wait out their TTL. */ - invalidateAll(): Promise; -} - -/** Narrows a {@link PolicyResolver} to one that can also drop what it memoized. */ -export function isPolicyInvalidator( - resolver: PolicyResolver, -): resolver is PolicyResolver & PolicyInvalidator { - const candidate = resolver as Partial; - return ( - typeof candidate.invalidate === "function" && - typeof candidate.invalidateAll === "function" - ); -} - -/** - * Checks that `roles` form a well-formed policy: every role named, no duplicates, every parent - * defined, no role inheriting from itself, and no inheritance cycles. - * - * Every backend calls it, so a policy rejected in a compiled-in build is rejected on its way into - * storage too. Throws {@link InvalidPolicyError}; returns nothing on success. - */ -export function validateRoles(roles: readonly Role[]): void { - const byName = new Map(); - for (const role of roles) { - if (role.name === "") { - throw new InvalidPolicyError("empty-role-name", "role name is empty"); - } - if (byName.has(role.name)) { - throw new InvalidPolicyError("duplicate-role", `duplicate role name: ${role.name}`); - } - byName.set(role.name, role); - } - - for (const role of roles) { - for (const parent of role.inherits ?? []) { - if (parent === role.name) { - throw new InvalidPolicyError( - "self-inheritance", - `role ${role.name} inherits from itself`, - ); - } - if (!byName.has(parent)) { - throw new InvalidPolicyError( - "unknown-parent-role", - `role ${role.name} inherits unknown role ${parent}`, - ); - } - } - } - - detectCycles(byName); -} - -/** - * Resolves every role to its effective permission set, with inheritance applied transitively. - * - * It validates first, so a malformed policy is rejected here rather than producing a - * partially-expanded result. This is the reference semantics for inheritance: any backend that - * expands some other way (in SQL, say) owes a test asserting it agrees with this function. - */ -export function expandInheritance(roles: readonly Role[]): Map { - validateRoles(roles); - - const byName = new Map(roles.map((r) => [r.name, r])); - const expanded = new Map(); - - const resolve = (name: string): PermissionSet => { - const memo = expanded.get(name); - if (memo !== undefined) { - return memo; - } - - const role = byName.get(name); - let set = new PermissionSet(role?.permissions ?? []); - - // Marked before recursing so a cycle would terminate here. Cycles are already rejected by - // validateRoles; this keeps the function total rather than relying on that from a distance. - expanded.set(name, set); - - for (const parent of role?.inherits ?? []) { - set = set.union(resolve(parent)); - expanded.set(name, set); - } - - return set; - }; - - for (const role of roles) { - resolve(role.name); - } - - return expanded; -} - -/** Walks the inheritance graph depth-first, reporting the first cycle it finds. */ -function detectCycles(byName: ReadonlyMap): void { - const state = new Map(); - - const walk = (name: string, path: readonly string[]): void => { - const seen = state.get(name); - if (seen === "visiting") { - throw new InvalidPolicyError( - "inheritance-cycle", - `role inheritance cycle: ${[...path, name].join(" -> ")}`, - ); - } - if (seen === "visited") { - return; - } - - state.set(name, "visiting"); - for (const parent of byName.get(name)?.inherits ?? []) { - walk(parent, [...path, name]); - } - state.set(name, "visited"); - }; - - // Sorted so a policy with more than one cycle reports the same one every run; an error that - // moves between runs is miserable to act on. - for (const name of [...byName.keys()].sort()) { - walk(name, []); - } -} diff --git a/packages/authorization/src/providers/static.test.ts b/packages/authorization/src/providers/static.test.ts deleted file mode 100644 index 34d37eb..0000000 --- a/packages/authorization/src/providers/static.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { isInvalidPolicy } from "../errors.js"; -import type { Role } from "../policy.js"; - -import { StaticPolicyResolver } from "./static.js"; - -const roles: Role[] = [ - { name: "reader", description: "reads recipes", permissions: ["recipes.read"] }, - { name: "author", permissions: ["recipes.create"], inherits: ["reader"] }, - { name: "billing", permissions: ["billing.refund"] }, -]; - -describe("StaticPolicyResolver", () => { - it("resolves a single role to its effective permissions", async () => { - const resolver = new StaticPolicyResolver(roles); - const set = await resolver.permissionsForRoles(["author"]); - expect(set.values()).toEqual(["recipes.create", "recipes.read"]); - }); - - it("unions several roles", async () => { - const resolver = new StaticPolicyResolver(roles); - const set = await resolver.permissionsForRoles(["author", "billing"]); - expect(set.values()).toEqual(["billing.refund", "recipes.create", "recipes.read"]); - }); - - it("resolves the same answer regardless of the order roles are given", () => { - const resolver = new StaticPolicyResolver(roles); - expect( - resolver - .resolve(["billing", "author"]) - .equals(resolver.resolve(["author", "billing"])), - ).toBe(true); - }); - - it("returns the memoized set on a repeat multi-role resolution", () => { - const resolver = new StaticPolicyResolver(roles); - expect(resolver.resolve(["author", "billing"])).toBe( - resolver.resolve(["author", "billing"]), - ); - }); - - it("grants nothing for no roles", async () => { - const resolver = new StaticPolicyResolver(roles); - expect((await resolver.permissionsForRoles([])).isEmpty()).toBe(true); - }); - - it("lets an unknown role contribute nothing rather than throwing", async () => { - // A principal still assigned a role the policy has since dropped loses that authority, - // rather than losing the ability to make requests at all. - const resolver = new StaticPolicyResolver(roles); - expect((await resolver.permissionsForRoles(["ghost"])).isEmpty()).toBe(true); - expect((await resolver.permissionsForRoles(["ghost", "billing"])).values()).toEqual([ - "billing.refund", - ]); - }); - - it("rejects a malformed policy at construction", () => { - // A policy mistake fails at startup rather than as a puzzling denial later. - try { - new StaticPolicyResolver([{ name: "loop", permissions: [], inherits: ["loop"] }]); - } catch (err) { - expect(isInvalidPolicy(err, "self-inheritance")).toBe(true); - return; - } - expect.unreachable("expected construction to throw"); - }); - - it("builds with no roles, denies everything, and warns", async () => { - // The default configuration has to build — but a service that denies every request is far - // more likely a missing configuration than an intent, so it says so. - const warn = vi.fn(); - const logger = { - debug: vi.fn(), - info: vi.fn(), - warn, - error: vi.fn(), - child: (): unknown => logger, - }; - - const resolver = new StaticPolicyResolver([], { - logger: logger as never, - }); - - expect((await resolver.permissionsForRoles(["anything"])).isEmpty()).toBe(true); - expect(warn).toHaveBeenCalledOnce(); - }); - - it("does not warn when a policy was supplied", () => { - const warn = vi.fn(); - const logger = { - debug: vi.fn(), - info: vi.fn(), - warn, - error: vi.fn(), - child: (): unknown => logger, - }; - - new StaticPolicyResolver(roles, { logger: logger as never }); - - expect(warn).not.toHaveBeenCalled(); - }); - - it("resolves synchronously for callers that know the policy is compiled in", () => { - // Public on purpose: a static policy genuinely resolves without I/O, and a caller should not - // have to await a promise that was never going to yield. - const resolver = new StaticPolicyResolver(roles); - expect(resolver.resolve(["reader"]).has("recipes.read")).toBe(true); - }); - - it("agrees with itself across the sync and async paths", async () => { - const resolver = new StaticPolicyResolver(roles); - const [sync, async] = [ - resolver.resolve(["author", "billing"]), - await resolver.permissionsForRoles(["author", "billing"]), - ]; - expect(sync.equals(async)).toBe(true); - }); -}); - -describe("StaticPolicyResolver policy isolation", () => { - it("copies the policy in, so a later mutation cannot edit it", async () => { - const permissions = ["recipes.read"]; - const mutable: Role[] = [{ name: "reader", permissions }]; - const resolver = new StaticPolicyResolver(mutable); - - permissions.push("recipes.delete"); - mutable.push({ name: "sneaky", permissions: ["everything"] }); - - expect((await resolver.permissionsForRoles(["reader"])).values()).toEqual([ - "recipes.read", - ]); - expect((await resolver.permissionsForRoles(["sneaky"])).isEmpty()).toBe(true); - }); - - it("hands out roles that share nothing with its own state", async () => { - const resolver = new StaticPolicyResolver(roles); - - const first = await resolver.roles(); - // Cast because `Role.permissions` is readonly: the point of the test is that reaching past - // the type still cannot touch the resolver's copy. - (first[0]?.permissions as string[]).push("injected"); - first.length = 0; - - const second = await resolver.roles(); - expect(second).toHaveLength(roles.length); - expect(second.find((r) => r.name === "reader")?.permissions).toEqual([ - "recipes.read", - ]); - // Two calls share no array either, so one caller's edit cannot reach another's copy. - expect(second[0]).not.toBe(first[0]); - }); - - it("reports every role, with descriptions and inheritance intact", async () => { - const listed = await new StaticPolicyResolver(roles).roles(); - expect(listed.map((r) => r.name).sort()).toEqual(["author", "billing", "reader"]); - expect(listed.find((r) => r.name === "reader")?.description).toBe("reads recipes"); - expect(listed.find((r) => r.name === "author")?.inherits).toEqual(["reader"]); - }); -}); diff --git a/packages/authorization/src/providers/static.ts b/packages/authorization/src/providers/static.ts deleted file mode 100644 index cb91fac..0000000 --- a/packages/authorization/src/providers/static.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { ensureLogger, type ObservabilityDeps } from "@primandproper/observability"; - -import { emptyPermissionSet, PermissionSet } from "../permission.js"; -import { expandInheritance, type PolicyResolver, type Role } from "../policy.js"; - -/** - * A {@link PolicyResolver} whose policy is fixed at construction. - * - * This is the default backend and the one to reach for until something forces otherwise: it needs - * no database, no migrations, and no configuration, and it resolves without I/O. Policy lives - * wherever the caller declares its roles — as TypeScript constants, or as JSON/YAML loaded into - * config. - * - * Graduate to a stored policy only when roles themselves must become editable data: when an - * operator has to define a new role, or change what an existing one grants, without shipping a - * release. Reassigning which roles a principal holds does not require it — role assignments belong - * to the consumer either way, because they reference the consumer's own users and tenants. This - * package owns policy, not assignment. - */ -export class StaticPolicyResolver implements PolicyResolver { - readonly #expanded: ReadonlyMap; - readonly #roles: readonly Role[]; - readonly #memo = new Map(); - - /** - * Builds a resolver over `roles`, expanding inheritance once. - * - * It throws {@link InvalidPolicyError} for a malformed policy — an unnamed role, a duplicate, a - * parent that is not defined, or an inheritance cycle — so a policy mistake fails at startup - * rather than as a puzzling denial later. - * - * Zero roles is valid and produces a resolver that denies everything. That is deliberate: the - * default configuration has to build, or the default provider would not be usable without setup. - * It logs a warning, because a service that denies every request is far more likely to be a - * missing configuration than an intent. - */ - constructor(roles: readonly Role[] = [], deps?: ObservabilityDeps) { - this.#expanded = expandInheritance(roles); - // Cloned so a caller mutating its own array afterwards cannot edit the live policy. - this.#roles = roles.map((role) => ({ - ...role, - permissions: [...role.permissions], - ...(role.inherits === undefined ? {} : { inherits: [...role.inherits] }), - })); - - if (roles.length === 0) { - ensureLogger(deps?.logger) - .child("authorization_static") - .warn( - "static policy resolver constructed with no roles; all authorization checks will deny", - ); - } - } - - /** - * The union of the effective permissions of the named roles. It never rejects: the policy was - * validated at construction and resolution touches nothing outside this process. - */ - async permissionsForRoles(roles: readonly string[]): Promise { - return this.resolve(roles); - } - - /** - * The synchronous core. It is public because a static policy genuinely resolves without I/O, and - * a caller that knows it configured this backend should not have to `await` a promise that was - * never going to yield. Code written against {@link PolicyResolver} uses the async method and - * stays portable across backends. - */ - resolve(roles: readonly string[]): PermissionSet { - const [only] = roles; - if (only === undefined) { - return emptyPermissionSet; - } - if (roles.length === 1) { - // The overwhelmingly common case, and it needs neither a union nor a memo entry: the - // expansion map already holds exactly this answer. - return this.#expanded.get(only) ?? emptyPermissionSet; - } - - const key = memoKey(roles); - const memo = this.#memo.get(key); - if (memo !== undefined) { - return memo; - } - - // Unknown roles contribute nothing rather than throwing, so a principal still assigned a role - // the policy has since dropped loses that authority instead of losing the ability to make - // requests at all. - const sets = roles - .map((name) => this.#expanded.get(name)) - .filter((s): s is PermissionSet => s !== undefined); - - const union = emptyPermissionSet.union(...sets); - this.#memo.set(key, union); - - return union; - } - - /** Every role in the policy. The result shares nothing with the resolver's state. */ - async roles(): Promise { - return this.#roles.map((role) => ({ - ...role, - permissions: [...role.permissions], - ...(role.inherits === undefined ? {} : { inherits: [...role.inherits] }), - })); - } -} - -/** - * Builds a stable key for a set of role names. It sorts a copy so the same roles in a different - * order hit the same entry, and joins on NUL because a role name cannot usefully contain one. - */ -function memoKey(roles: readonly string[]): string { - return [...roles].sort().join("\u0000"); -} diff --git a/packages/authorization/tsconfig.json b/packages/authorization/tsconfig.json deleted file mode 100644 index d86106a..0000000 --- a/packages/authorization/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"], - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/authorization/tsup.config.ts b/packages/authorization/tsup.config.ts deleted file mode 100644 index a4142df..0000000 --- a/packages/authorization/tsup.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig([ - { - entry: { "index.node": "src/index.node.ts" }, - platform: "node", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: true, - }, - { - entry: { "index.browser": "src/index.browser.ts" }, - platform: "browser", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: false, - }, -]); diff --git a/packages/database/CHANGELOG.md b/packages/database/CHANGELOG.md deleted file mode 100644 index 30e61df..0000000 --- a/packages/database/CHANGELOG.md +++ /dev/null @@ -1,14 +0,0 @@ -# @primandproper/database - -## 0.1.0 - -### Minor Changes - -- a124406: Add a required `ensureReady()` method to `DatabaseClient` and change `mysqlDsn()` output to a `mysql://` URL. Breaking for custom `DatabaseClient` implementers and consumers of the previous DSN format. Adds pool-settings helpers. - -### Patch Changes - -- Updated dependencies [a124406] -- Updated dependencies [a124406] - - @primandproper/errors@0.0.2 - - @primandproper/observability@0.1.0 diff --git a/packages/database/README.md b/packages/database/README.md deleted file mode 100644 index 52d7d66..0000000 --- a/packages/database/README.md +++ /dev/null @@ -1,116 +0,0 @@ -# @primandproper/database - -Server-only instrumented database pools: a thin observability + lifecycle layer over a driver pool, -for Drizzle / Kysely / raw SQL. A **narrow** port of platform-go's `database`. - -## Scope - -The TS ecosystem's query builders (Drizzle, Kysely) and Prisma own connection management, so this is -deliberately a narrow slice — **no query-executor inheritance, no migrations**. It provides: - -- **Config + DSN builders** (`postgres` / `mysql` / `sqlite`), matching platform-go's defaults. -- An **instrumented client** (`DatabaseClient`) over a `QueryablePool` seam: spans on queries, - ping-with-retry readiness, lifecycle, and an injectable clock. -- **Transactions** (`withTransaction`) on a pinned write connection. -- **Structural adapters** (`pgPool`, `mysqlPool`, `sqlitePool`) so the package depends on no driver — - you bring your own `pg` / `mysql2` / `better-sqlite3` instance. - -## Usage - -```ts -import { Pool } from "pg"; -import { - DatabaseConfigSchema, - readConnectionString, - pgPool, - provideDatabase, -} from "@primandproper/database"; - -const config = DatabaseConfigSchema.parse({ - provider: "postgres", - read: { username: "app", password: "secret", database: "app", host: "db", port: 5432 }, -}); - -const raw = new Pool({ connectionString: readConnectionString(config) }); -const db = provideDatabase(config, { read: pgPool(raw) }, { logger }); - -if (!(await db.isReady())) throw new Error("database not ready"); - -// Hand db.readPool / db.writePool to Drizzle/Kysely, or use the instrumented helper: -const { rows } = await db.query(db.readPool, "SELECT 1"); -await db.close(); -``` - -`sqlite` (via `better-sqlite3`) applies WAL journaling and foreign-key enforcement and is inherently -single-writer, matching platform-go. - -## Transactions - -`withTransaction` runs its callback on **one pinned connection from the write pool**. Resolving -commits; rejecting rolls back and re-throws the original error unchanged. - -```ts -const orderId = await db.withTransaction(async (q) => { - const { rows } = await q.query( - "INSERT INTO orders (total) VALUES ($1) RETURNING id", - [99], - ); - await q.query("INSERT INTO order_events (order_id, kind) VALUES ($1, 'created')", [ - rows[0].id, - ]); - return rows[0].id; -}); -``` - -The callback receives a bare `Queryable` — `query` and nothing else. It cannot commit, roll back, -close the pool, or outlive the closure; that lifecycle belongs to `withTransaction`, which acquires -the connection and releases it in a `finally`. `QueryablePool extends Queryable`, so a helper written -against the narrow type takes either: - -```ts -// Works with db.readPool, db.writePool, or the transaction's q. -async function findOrder(q: Queryable, id: string) { … } -``` - -That is the seam the `outbox` port is built on: holding a `Queryable` handed out by -`withTransaction` _means_ you are inside a transaction, so an enqueue cannot escape one by accident. - -Behaviour worth knowing: - -| situation | result | -| ------------------------------ | ------------------------------------------------------------------------------------------------------ | -| callback resolves | `COMMIT`; its value is returned | -| callback rejects | `ROLLBACK`; the **original** error is re-thrown (a failed rollback is logged, not surfaced) | -| `COMMIT` fails | `TransactionCommitError` (`database/commit-failed`), driver error as `cause`; no rollback is attempted | -| nested on the same client | `NestedTransactionError` (`database/nested-transaction`) — there are no savepoints | -| nested on a _different_ client | allowed; two databases share no commit and this package does not pretend otherwise | -| write pool has no `connect()` | `TransactionsUnsupportedError` (`database/transactions-unsupported`) | - -Nesting is detected per async context, so concurrent transactions on one client — the normal case -for a server handling many requests — are unaffected. - -Statements inside the callback can still be instrumented: `db.query(q, "…")` accepts the narrow -`Queryable`. - -### Dialect notes - -`BEGIN` is issued by default, which postgres, mysql, and sqlite all accept. Override it with the -`beginStatement` dep where the default locking mode is wrong — notably sqlite, where -`BEGIN IMMEDIATE` takes the write lock up front instead of failing with `SQLITE_BUSY` when a -deferred transaction tries to upgrade mid-flight: - -```ts -const db = provideDatabase( - config, - { read: sqlitePool(raw) }, - { - logger, - beginStatement: "BEGIN IMMEDIATE", - }, -); -``` - -`better-sqlite3` has a single connection and no nested transactions — a second `BEGIN` silently -joins the first one's scope, and the first `COMMIT` commits both. `sqlitePool`'s `connect()` is -therefore a write lock rather than a pool slot: concurrent `withTransaction` calls queue in arrival -order instead of interleaving. diff --git a/packages/database/package.json b/packages/database/package.json deleted file mode 100644 index 0f858eb..0000000 --- a/packages/database/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@primandproper/database", - "version": "0.1.0", - "type": "module", - "description": "Server-only instrumented database pools: a thin observability + lifecycle layer over a driver pool, for Drizzle/Kysely/raw SQL.", - "license": "AGPL-3.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/primandproper/platform-ts.git", - "directory": "packages/database" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "eslint .", - "clean": "rm -rf dist .turbo" - }, - "dependencies": { - "@primandproper/errors": "workspace:*", - "@primandproper/observability": "workspace:*", - "zod": "3.25.76" - } -} diff --git a/packages/database/src/adapters.ts b/packages/database/src/adapters.ts deleted file mode 100644 index acebdd9..0000000 --- a/packages/database/src/adapters.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { PooledConnection, QueryablePool, QueryResult } from "./database.js"; - -/** - * Adapters that normalize a driver's pool to {@link QueryablePool}. They are structurally typed, so - * this package depends on no driver: the caller constructs `pg`/`mysql2`/`better-sqlite3` with the - * connection string from `config.ts` and hands the instance here. - * - * Each adapter also supplies {@link QueryablePool.connect}, since a transaction is a pinned - * connection rather than a pool, and only the driver knows how to check one out. - */ - -/** The subset of a `node-postgres` `PoolClient` used here. */ -export interface PgClientLike { - query( - text: string, - params?: readonly unknown[], - ): Promise<{ rows: unknown[]; rowCount?: number | null }>; - release(): void; -} - -/** The subset of a `node-postgres` `Pool` used here. */ -export interface PgPoolLike { - query( - text: string, - params?: readonly unknown[], - ): Promise<{ rows: unknown[]; rowCount?: number | null }>; - /** Absent only in a stand-in; a real `pg.Pool` always has it. */ - connect?(): Promise; - end(): Promise; -} - -function pgResult(raw: { rows: unknown[]; rowCount?: number | null }): QueryResult { - const result: QueryResult = { rows: raw.rows }; - if (raw.rowCount != null) result.rowCount = raw.rowCount; - return result; -} - -/** Adapts a `node-postgres` `Pool`. */ -export function pgPool(pool: PgPoolLike): QueryablePool { - const adapted: QueryablePool = { - async query(text, params): Promise { - return pgResult(await pool.query(text, params)); - }, - end: () => pool.end(), - }; - const connect = pool.connect?.bind(pool); - if (connect) { - adapted.connect = async (): Promise => { - const client = await connect(); - let released = false; - return { - async query(text, params): Promise { - return pgResult(await client.query(text, params)); - }, - // `pg` throws on a double release; guard so a release racing the pool's own error handling - // cannot turn cleanup into the error the caller sees. - release(): void { - if (released) return; - released = true; - client.release(); - }, - }; - }; - } - return adapted; -} - -/** The subset of a `mysql2/promise` `PoolConnection` used here. */ -export interface MysqlConnectionLike { - query(sql: string, values?: readonly unknown[]): Promise<[unknown, unknown]>; - release(): void; -} - -/** The subset of a `mysql2/promise` `Pool` used here. */ -export interface MysqlPoolLike { - query(sql: string, values?: readonly unknown[]): Promise<[unknown, unknown]>; - /** Absent only in a stand-in; a real `mysql2/promise` `Pool` always has it. */ - getConnection?(): Promise; - end(): Promise; -} - -function mysqlResult(rows: unknown): QueryResult { - if (Array.isArray(rows)) return { rows }; - const header = rows as { affectedRows?: number }; - const result: QueryResult = { rows: [] }; - if (typeof header.affectedRows === "number") result.rowCount = header.affectedRows; - return result; -} - -/** Adapts a `mysql2/promise` `Pool`. Non-`SELECT` results (a header, not rows) yield empty `rows`. */ -export function mysqlPool(pool: MysqlPoolLike): QueryablePool { - const adapted: QueryablePool = { - async query(text, params): Promise { - const [rows] = await pool.query(text, params); - return mysqlResult(rows); - }, - end: () => pool.end(), - }; - const getConnection = pool.getConnection?.bind(pool); - if (getConnection) { - adapted.connect = async (): Promise => { - const connection = await getConnection(); - let released = false; - return { - async query(text, params): Promise { - const [rows] = await connection.query(text, params); - return mysqlResult(rows); - }, - release(): void { - if (released) return; - released = true; - connection.release(); - }, - }; - }; - } - return adapted; -} - -/** The subset of a `better-sqlite3` prepared statement used here. */ -export interface SqliteStatementLike { - /** `true` when the statement returns rows; `false`/absent for writes. */ - reader?: boolean; - all(...params: readonly unknown[]): unknown[]; - run(...params: readonly unknown[]): { changes: number }; -} - -/** The subset of a `better-sqlite3` `Database` used here. */ -export interface SqliteDatabaseLike { - prepare(sql: string): SqliteStatementLike; - pragma(source: string): unknown; - close(): void; -} - -/** - * Adapts a `better-sqlite3` `Database`. By default applies WAL journaling and foreign-key - * enforcement, matching platform-go's sqlite client. The connection is inherently single-writer. - */ -export function sqlitePool( - db: SqliteDatabaseLike, - options: { applyPragmas?: boolean } = {}, -): QueryablePool { - if (options.applyPragmas !== false) { - db.pragma("journal_mode = WAL"); - db.pragma("foreign_keys = ON"); - } - // `async` so a synchronous `prepare` throw (invalid SQL) rejects the returned promise rather - // than escaping synchronously from this Promise-typed method. - const exec = async ( - text: string, - params: readonly unknown[] = [], - ): Promise => { - const statement = db.prepare(text); - // Only a row-returning statement (`reader === true`: SELECT, or `... RETURNING`) may use - // `all()`; a write is `reader === false` OR absent per SqliteStatementLike's contract, and - // calling `all()` on it throws in better-sqlite3. Route by truthiness so absent means write. - if (statement.reader) { - return { rows: statement.all(...params) }; - } - const info = statement.run(...params); - return { rows: [], rowCount: info.changes }; - }; - - // There is one connection, so "check one out" means "take the write lock". Without this a second - // concurrent transaction would interleave its BEGIN into the first one's scope and commit both at - // the first COMMIT — sqlite has no nested transactions to catch it. Callers queue instead. - let tail: Promise = Promise.resolve(); - - return { - query: exec, - async connect(): Promise { - let handOff!: () => void; - const held = new Promise((resolve) => { - handOff = resolve; - }); - const ahead = tail; - // Claim the slot synchronously, before the first await, so concurrent callers queue in - // arrival order rather than all racing on the same value of `tail`. - tail = ahead.then(() => held); - await ahead; - - let released = false; - return { - query: exec, - release(): void { - if (released) return; - released = true; - handOff(); - }, - }; - }, - end(): Promise { - db.close(); - return Promise.resolve(); - }, - }; -} diff --git a/packages/database/src/config.ts b/packages/database/src/config.ts deleted file mode 100644 index 4585136..0000000 --- a/packages/database/src/config.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { z } from "zod"; - -/** The supported SQL dialects. */ -export const DatabaseProviders = ["postgres", "mysql", "sqlite"] as const; -export type DatabaseProvider = (typeof DatabaseProviders)[number]; - -/** - * Connection coordinates for one endpoint. For `sqlite`, only `database` (the file path, or - * `:memory:`) is meaningful; the network fields are ignored. - */ -export const ConnectionDetailsSchema = z.object({ - username: z.string().default(""), - password: z.string().default(""), - database: z.string().default(""), - host: z.string().default("localhost"), - port: z.number().int().nonnegative().default(5432), - disableSSL: z.boolean().default(false), -}); - -export type ConnectionDetails = z.infer; -export type ConnectionDetailsInput = z.input; - -/** Default pool and ping settings, mirroring platform-go's `databasecfg` defaults. */ -export const DEFAULT_MAX_IDLE_CONNS = 5; -export const DEFAULT_MAX_OPEN_CONNS = 7; -export const DEFAULT_CONN_MAX_LIFETIME_MS = 30 * 60 * 1000; -export const DEFAULT_PING_WAIT_PERIOD_MS = 1000; - -/** - * Database config. The `read` endpoint is required; `write` defaults to `read` (single endpoint). - * Pool/ping settings carry the same defaults as platform-go. - */ -export const DatabaseConfigSchema = z.object({ - provider: z.enum(DatabaseProviders).default("postgres"), - read: ConnectionDetailsSchema, - write: ConnectionDetailsSchema.optional(), - maxIdleConns: z.number().int().positive().default(DEFAULT_MAX_IDLE_CONNS), - maxOpenConns: z.number().int().positive().default(DEFAULT_MAX_OPEN_CONNS), - connMaxLifetimeMs: z.number().int().nonnegative().default(DEFAULT_CONN_MAX_LIFETIME_MS), - /** Retries after the first failed ping before {@link DatabaseClient.isReady} gives up. */ - maxPingAttempts: z.number().int().nonnegative().default(0), - pingWaitPeriodMs: z.number().int().nonnegative().default(DEFAULT_PING_WAIT_PERIOD_MS), -}); - -export type DatabaseConfig = z.infer; -export type DatabaseConfigInput = z.input; - -/** - * Quotes a libpq key=value component. An empty value, or one containing whitespace, a single - * quote, or a backslash, is single-quoted with `'` and `\` backslash-escaped — otherwise a - * password like `p@ss word` would silently truncate the DSN at the space. - */ -function pgKeyValueQuote(value: string): string { - if (value === "" || /[\s'\\]/u.test(value)) { - return `'${value.replace(/(['\\])/gu, "\\$1")}'`; - } - return value; -} - -/** Postgres key=value DSN (`pgx`/libpq style). Components are quoted so special chars survive. */ -export function postgresKeyValue(cd: ConnectionDetails): string { - return `user=${pgKeyValueQuote(cd.username)} password=${pgKeyValueQuote(cd.password)} database=${pgKeyValueQuote(cd.database)} host=${pgKeyValueQuote(cd.host)} port=${String(cd.port)}`; -} - -/** - * Postgres connection URI, the idiomatic form for `node-postgres`. (platform-go hands `pgx` a - * key=value DSN; the URI is equivalent and what JS drivers expect.) - */ -export function postgresUri(cd: ConnectionDetails): string { - const auth = `${encodeURIComponent(cd.username)}:${encodeURIComponent(cd.password)}`; - const base = `postgres://${auth}@${cd.host}:${String(cd.port)}/${encodeURIComponent(cd.database)}`; - return cd.disableSSL ? `${base}?sslmode=disable` : base; -} - -/** - * MySQL connection URI (`mysql://user:pass@host:port/db`) — the form `mysql2` parses. platform-go - * hands `go-sql-driver` its `user:pass@tcp(host:port)/db` DSN, which no JS driver understands; the - * URI is the equivalent JS drivers expect. Credentials are percent-encoded so `@`/`:`/`/` survive. - */ -export function mysqlDsn(cd: ConnectionDetails): string { - const auth = `${encodeURIComponent(cd.username)}:${encodeURIComponent(cd.password)}`; - return `mysql://${auth}@${cd.host}:${String(cd.port)}/${encodeURIComponent(cd.database)}`; -} - -/** SQLite "DSN" — just the database file path. */ -export function sqlitePath(cd: ConnectionDetails): string { - return cd.database; -} - -/** - * `node-postgres` `Pool` options derived from the driver-agnostic pool config, so these settings - * actually reach a pool rather than being parsed and discarded. `maxOpenConns` → `max`; - * `connMaxLifetimeMs` → `maxLifetimeSeconds`. pg bounds only the total client count (`max`) and has - * no separate idle cap, so `maxIdleConns` has no pg equivalent and is intentionally not mapped. - * Spread into pool construction: `new Pool({ connectionString, ...pgPoolSettings(cfg) })`. - */ -export function pgPoolSettings(config: DatabaseConfig): { - max: number; - maxLifetimeSeconds: number; -} { - return { - max: config.maxOpenConns, - maxLifetimeSeconds: Math.ceil(config.connMaxLifetimeMs / 1000), - }; -} - -/** - * `mysql2` `Pool` options derived from the pool config. `maxOpenConns` → `connectionLimit`; - * `maxIdleConns` → `maxIdle`. mysql2 has no per-connection max-lifetime, so `connMaxLifetimeMs` is - * intentionally not mapped. Spread into pool construction: - * `mysql.createPool({ uri, ...mysqlPoolSettings(cfg) })`. - */ -export function mysqlPoolSettings(config: DatabaseConfig): { - connectionLimit: number; - maxIdle: number; -} { - return { - connectionLimit: config.maxOpenConns, - maxIdle: config.maxIdleConns, - }; -} - -function connectionStringFor(provider: DatabaseProvider, cd: ConnectionDetails): string { - switch (provider) { - case "postgres": - return postgresUri(cd); - case "mysql": - return mysqlDsn(cd); - case "sqlite": - return sqlitePath(cd); - } -} - -/** The read endpoint's connection string for the configured provider. */ -export function readConnectionString(config: DatabaseConfig): string { - return connectionStringFor(config.provider, config.read); -} - -/** The write endpoint's connection string, falling back to the read endpoint. */ -export function writeConnectionString(config: DatabaseConfig): string { - return connectionStringFor(config.provider, config.write ?? config.read); -} diff --git a/packages/database/src/database.test.ts b/packages/database/src/database.test.ts deleted file mode 100644 index c1ec0d3..0000000 --- a/packages/database/src/database.test.ts +++ /dev/null @@ -1,842 +0,0 @@ -import type { Logger } from "@primandproper/observability"; -import { describe, expect, it, vi } from "vitest"; - -import { - mysqlPool, - mysqlPoolSettings, - pgPool, - pgPoolSettings, - postgresKeyValue, - postgresUri, - mysqlDsn, - provideDatabase, - readConnectionString, - sqlitePath, - sqlitePool, - writeConnectionString, - DatabaseConfigSchema, - type PooledConnection, - type Queryable, - type QueryablePool, - type QueryResult, -} from "./index.js"; - -interface ProbePool extends QueryablePool { - ended: number; - calls: () => number; -} - -/** A fake pool that fails its first `failuresBeforeSuccess` queries, then succeeds. */ -function probePool(failuresBeforeSuccess = 0): ProbePool { - let calls = 0; - const pool: ProbePool = { - ended: 0, - calls: () => calls, - query(): Promise { - calls += 1; - if (calls <= failuresBeforeSuccess) return Promise.reject(new Error("down")); - return Promise.resolve({ rows: [{ ok: 1 }] }); - }, - end(): Promise { - pool.ended += 1; - return Promise.resolve(); - }, - }; - return pool; -} - -const cd = ( - over: Record = {}, -): ReturnType["read"] => - DatabaseConfigSchema.parse({ read: over }).read; - -/** A logger that records error lines so LC-13's readiness/close paths can be asserted. */ -function recordingLogger(): { - logger: Logger; - errors: { msg: string; err: unknown; values?: unknown }[]; -} { - const errors: { msg: string; err: unknown; values?: unknown }[] = []; - const logger: Logger = { - debug: () => undefined, - info: () => undefined, - warn: () => undefined, - error: (msg, err, values) => errors.push({ msg, err, values }), - with: () => logger, - child: () => logger, - withSpan: () => logger, - }; - return { logger, errors }; -} - -interface TxPool extends QueryablePool { - /** Every statement issued on a pinned connection, in order. */ - statements: string[]; - checkouts: number; - releases: number; -} - -/** A pool that pins a connection and records the transaction's statements and lifecycle. */ -function txPool(failOn: (text: string) => Error | undefined = () => undefined): TxPool { - const pool: TxPool = { - statements: [], - checkouts: 0, - releases: 0, - query: () => Promise.resolve({ rows: [] }), - end: () => Promise.resolve(), - connect(): Promise { - pool.checkouts += 1; - let released = false; - return Promise.resolve({ - query(text): Promise { - pool.statements.push(text); - const failure = failOn(text); - return failure ? Promise.reject(failure) : Promise.resolve({ rows: [] }); - }, - release(): void { - if (released) return; - released = true; - pool.releases += 1; - }, - }); - }, - }; - return pool; -} - -describe("config", () => { - it("applies the documented defaults", () => { - const cfg = DatabaseConfigSchema.parse({ read: {} }); - expect(cfg).toMatchObject({ - provider: "postgres", - maxIdleConns: 5, - maxOpenConns: 7, - connMaxLifetimeMs: 30 * 60 * 1000, - maxPingAttempts: 0, - pingWaitPeriodMs: 1000, - }); - }); - - it("builds a postgres URI, adding sslmode only when SSL is disabled", () => { - const details = cd({ - username: "u", - password: "p@s", - database: "db", - host: "h", - port: 5432, - }); - expect(postgresUri(details)).toBe("postgres://u:p%40s@h:5432/db"); - expect(postgresUri({ ...details, disableSSL: true })).toBe( - "postgres://u:p%40s@h:5432/db?sslmode=disable", - ); - }); - - it("builds the key=value, mysql, and sqlite forms", () => { - const details = cd({ - username: "u", - password: "p", - database: "db", - host: "h", - port: 3306, - }); - expect(postgresKeyValue(details)).toBe( - "user=u password=p database=db host=h port=3306", - ); - // DB-2: a `mysql://` URI (with percent-encoded credentials), not Go's `@tcp(...)` DSN. - expect(mysqlDsn(details)).toBe("mysql://u:p@h:3306/db"); - expect(mysqlDsn({ ...details, password: "p@s:w/d" })).toBe( - "mysql://u:p%40s%3Aw%2Fd@h:3306/db", - ); - expect(sqlitePath({ ...details, database: "/tmp/app.db" })).toBe("/tmp/app.db"); - }); - - it("DB-3: escapes/encodes special characters in DSN components", () => { - const details = cd({ - username: "u", - password: "p@ss word", - database: "my db", - host: "h", - port: 5432, - }); - // libpq key=value: a value with a space (or quote/backslash) is single-quoted, not left bare - // — otherwise the DSN truncates at the space. - expect(postgresKeyValue(details)).toBe( - "user=u password='p@ss word' database='my db' host=h port=5432", - ); - expect(postgresKeyValue({ ...details, password: "a'b\\c" })).toContain( - "password='a\\'b\\\\c'", - ); - // URI forms percent-encode the database segment too, not just the credentials. - expect(postgresUri(details)).toBe("postgres://u:p%40ss%20word@h:5432/my%20db"); - expect(mysqlDsn(details)).toBe("mysql://u:p%40ss%20word@h:5432/my%20db"); - }); - - it("derives connection strings per provider and falls back write→read", () => { - const cfg = DatabaseConfigSchema.parse({ - provider: "mysql", - read: { username: "u", password: "p", database: "db", host: "h", port: 3306 }, - }); - expect(readConnectionString(cfg)).toBe("mysql://u:p@h:3306/db"); - expect(writeConnectionString(cfg)).toBe(readConnectionString(cfg)); - }); - - // DB-1: pool config maps to real driver options instead of being parsed and discarded. - it("maps pool config onto pg and mysql2 pool options", () => { - const cfg = DatabaseConfigSchema.parse({ - read: { database: "db" }, - maxOpenConns: 12, - maxIdleConns: 4, - connMaxLifetimeMs: 90_000, - }); - expect(pgPoolSettings(cfg)).toStrictEqual({ max: 12, maxLifetimeSeconds: 90 }); - expect(mysqlPoolSettings(cfg)).toStrictEqual({ connectionLimit: 12, maxIdle: 4 }); - }); -}); - -describe("provideDatabase", () => { - const config = { provider: "sqlite", read: { database: ":memory:" } } as const; - - it("uses the injected clock for currentTime", () => { - const now = new Date("2026-06-30T00:00:00.000Z"); - const client = provideDatabase(config, { read: probePool() }, { now: () => now }); - expect(client.currentTime()).toBe(now); - }); - - it("shares the write pool with the read pool when only one is given", async () => { - const read = probePool(); - const client = provideDatabase(config, { read }); - expect(client.writePool).toBe(client.readPool); - await client.close(); - expect(read.ended).toBe(1); - }); - - it("closes both distinct pools", async () => { - const read = probePool(); - const write = probePool(); - await provideDatabase(config, { read, write }).close(); - expect(read.ended).toBe(1); - expect(write.ended).toBe(1); - }); - - // LC-13: a failing read-pool end() must not skip the write pool; the failure is logged + surfaced. - it("drains the write pool even when the read pool's end() rejects, then surfaces the error", async () => { - const boom = new Error("read drain failed"); - const read = probePool(); - read.end = () => Promise.reject(boom); - const write = probePool(); - const { logger, errors } = recordingLogger(); - - const client = provideDatabase(config, { read, write }, { logger }); - await expect(client.close()).rejects.toBe(boom); - - expect(write.ended).toBe(1); // write pool still drained despite the read failure - expect(errors.map((e) => e.err)).toContain(boom); - }); - - it("reports ready when the ping succeeds", async () => { - const client = provideDatabase(config, { read: probePool() }); - await expect(client.isReady()).resolves.toBe(true); - }); - - // DB-3: DatabaseNotReadyError is no longer an exported-but-never-thrown error. - it("ensureReady resolves when ready and rejects with DatabaseNotReadyError otherwise", async () => { - const ready = provideDatabase(config, { read: probePool() }); - await expect(ready.ensureReady()).resolves.toBeUndefined(); - - const down = provideDatabase(config, { read: probePool(Infinity) }); - await expect(down.ensureReady()).rejects.toMatchObject({ - code: "database/not-ready", - }); - }); - - it("reports not ready, without waiting, when pings fail and no retries are configured", async () => { - const sleep = vi.fn<(ms: number) => Promise>().mockResolvedValue(undefined); - const client = provideDatabase(config, { read: probePool(Infinity) }, { sleep }); - await expect(client.isReady()).resolves.toBe(false); - expect(sleep).not.toHaveBeenCalled(); - }); - - // LC-13: a readiness failure logs the driver error at error level, not a bare debug line. - it("logs the ping failure cause at error level when it gives up", async () => { - const { logger, errors } = recordingLogger(); - const client = provideDatabase(config, { read: probePool(Infinity) }, { logger }); - - await expect(client.isReady()).resolves.toBe(false); - expect(errors).toHaveLength(1); - expect(errors[0]?.msg).toMatch(/ping failed for read/); - expect((errors[0]?.err as Error).message).toBe("down"); - expect(errors[0]?.values).toMatchObject({ connection: "read" }); - }); - - it("retries the ping up to maxPingAttempts before succeeding", async () => { - const sleep = vi.fn<(ms: number) => Promise>().mockResolvedValue(undefined); - const read = probePool(2); - const client = provideDatabase( - { ...config, maxPingAttempts: 2 }, - { read }, - { sleep }, - ); - await expect(client.isReady()).resolves.toBe(true); - expect(sleep).toHaveBeenCalledTimes(2); - expect(read.calls()).toBe(3); - }); - - it("is not ready when a distinct write pool stays down", async () => { - const client = provideDatabase(config, { - read: probePool(), - write: probePool(Infinity), - }); - await expect(client.isReady()).resolves.toBe(false); - }); - - it("instruments and returns query rows", async () => { - const client = provideDatabase(config, { read: probePool() }); - const result = await client.query(client.readPool, "SELECT 1"); - expect(result.rows).toStrictEqual([{ ok: 1 }]); - }); -}); - -describe("adapters", () => { - it("pgPool normalizes rows and rowCount", async () => { - const adapted = pgPool({ - query: () => Promise.resolve({ rows: [{ id: 1 }], rowCount: 1 }), - end: () => Promise.resolve(), - }); - await expect(adapted.query("SELECT 1")).resolves.toStrictEqual({ - rows: [{ id: 1 }], - rowCount: 1, - }); - }); - - it("mysqlPool unwraps the rows tuple and maps a header to rowCount", async () => { - const selectPool = mysqlPool({ - query: () => Promise.resolve([[{ id: 1 }], []]), - end: () => Promise.resolve(), - }); - await expect(selectPool.query("SELECT 1")).resolves.toStrictEqual({ - rows: [{ id: 1 }], - }); - - const writePool = mysqlPool({ - query: () => Promise.resolve([{ affectedRows: 3 }, undefined]), - end: () => Promise.resolve(), - }); - await expect(writePool.query("DELETE FROM t")).resolves.toStrictEqual({ - rows: [], - rowCount: 3, - }); - }); - - it("sqlitePool applies pragmas and routes readers vs writers", async () => { - const pragmas: string[] = []; - const adapted = sqlitePool({ - prepare: (sql: string) => ({ - reader: sql.startsWith("SELECT"), - all: () => [{ n: 1 }], - run: () => ({ changes: 2 }), - }), - pragma: (source: string) => { - pragmas.push(source); - return undefined; - }, - close: () => undefined, - }); - - expect(pragmas).toStrictEqual(["journal_mode = WAL", "foreign_keys = ON"]); - await expect(adapted.query("SELECT 1")).resolves.toStrictEqual({ rows: [{ n: 1 }] }); - await expect(adapted.query("INSERT INTO t VALUES (1)")).resolves.toStrictEqual({ - rows: [], - rowCount: 2, - }); - }); - - it("DB-3: sqlitePool routes a reader-absent statement to run(), not all()", async () => { - let allCalls = 0; - const adapted = sqlitePool( - { - // A write statement whose `reader` is absent (per the seam contract). all() would throw - // in better-sqlite3 ("does not return data"); it must go to run(). - prepare: () => ({ - all: () => { - allCalls += 1; - throw new Error("all() called on a non-reader statement"); - }, - run: () => ({ changes: 4 }), - }), - pragma: () => undefined, - close: () => undefined, - }, - { applyPragmas: false }, - ); - await expect(adapted.query("INSERT INTO t VALUES (1)")).resolves.toStrictEqual({ - rows: [], - rowCount: 4, - }); - expect(allCalls).toBe(0); - }); - - it("DB-3: sqlitePool rejects (does not synchronously throw) when prepare fails", async () => { - const adapted = sqlitePool( - { - prepare: () => { - throw new Error("syntax error"); - }, - pragma: () => undefined, - close: () => undefined, - }, - { applyPragmas: false }, - ); - // A synchronous throw from prepare would escape the Promise-typed method; assert it rejects. - await expect(adapted.query("NOT SQL")).rejects.toThrow(/syntax error/); - }); -}); - -describe("withTransaction", () => { - const config = { provider: "postgres", read: { database: "db" } } as const; - - const client = ( - write: QueryablePool, - deps: Parameters[2] = {}, - ): ReturnType => - provideDatabase(config, { read: probePool(), write }, deps); - - it("wraps the callback in BEGIN/COMMIT and returns its value", async () => { - const write = txPool(); - const db = client(write); - - await expect( - db.withTransaction(async (q) => { - await q.query("INSERT INTO t VALUES ($1)", [1]); - return "ok"; - }), - ).resolves.toBe("ok"); - - expect(write.statements).toStrictEqual([ - "BEGIN", - "INSERT INTO t VALUES ($1)", - "COMMIT", - ]); - }); - - it("rolls back and re-throws the callback's error unchanged", async () => { - const write = txPool(); - const boom = new Error("callback failed"); - - await expect( - client(write).withTransaction(async (q) => { - await q.query("INSERT INTO t VALUES ($1)", [1]); - throw boom; - }), - ).rejects.toBe(boom); - - expect(write.statements).toStrictEqual([ - "BEGIN", - "INSERT INTO t VALUES ($1)", - "ROLLBACK", - ]); - }); - - // A transaction on a read replica either errors or silently discards its writes, so the read - // pool must not be touched even when it is the one that was configured first. - it("runs on the write pool, never the read pool", async () => { - const read = probePool(); - const write = txPool(); - const db = provideDatabase(config, { read, write }); - - await db.withTransaction(async (q) => { - await q.query("UPDATE t SET n = 1"); - }); - - expect(write.checkouts).toBe(1); - expect(read.calls()).toBe(0); - }); - - it("releases the connection on commit, on rollback, and when BEGIN itself fails", async () => { - const committed = txPool(); - await client(committed).withTransaction(async () => undefined); - expect(committed).toMatchObject({ checkouts: 1, releases: 1 }); - - const rolledBack = txPool(); - await expect( - client(rolledBack).withTransaction(() => Promise.reject(new Error("no"))), - ).rejects.toThrow("no"); - expect(rolledBack).toMatchObject({ checkouts: 1, releases: 1 }); - - // BEGIN failing means there is no transaction to roll back, but there is still a connection - // checked out — the case a `finally`-less implementation leaks. - const cannotBegin = txPool((text) => - text === "BEGIN" ? new Error("cannot begin") : undefined, - ); - await expect( - client(cannotBegin).withTransaction(async () => undefined), - ).rejects.toThrow("cannot begin"); - expect(cannotBegin.statements).toStrictEqual(["BEGIN"]); - expect(cannotBegin).toMatchObject({ checkouts: 1, releases: 1 }); - }); - - it("leaks no connection across many sequential transactions, half of which fail", async () => { - const write = txPool(); - const db = client(write); - - for (let i = 0; i < 100; i += 1) { - const attempt = db.withTransaction(async (q) => { - await q.query("SELECT 1"); - if (i % 2 === 0) throw new Error(`failure ${String(i)}`); - }); - if (i % 2 === 0) await expect(attempt).rejects.toThrow(`failure ${String(i)}`); - else await attempt; - } - - expect(write.checkouts).toBe(100); - expect(write.releases).toBe(100); - }); - - // The error that caused the rollback is the diagnosis; the cleanup failure is a footnote. Losing - // the former to report the latter is the bug this guards. - it("preserves the original error when the rollback itself fails, and logs the rollback", async () => { - const write = txPool((text) => - text === "ROLLBACK" ? new Error("connection reset") : undefined, - ); - const { logger, errors } = recordingLogger(); - const boom = new Error("callback failed"); - - await expect( - client(write, { logger }).withTransaction(() => Promise.reject(boom)), - ).rejects.toBe(boom); - - expect(write.releases).toBe(1); - const rollbackLine = errors.find((e) => e.msg === "rolling back transaction"); - expect((rollbackLine?.err as Error).message).toBe("connection reset"); - expect(rollbackLine?.values).toMatchObject({ rollbackCause: "callback failed" }); - }); - - // A failed commit leaves the outcome unknown, which is a different fact from "the callback threw" - // — and the driver has already ended the transaction, so a rollback would only raise a spurious - // "no transaction in progress". - it("reports a failed commit as TransactionCommitError and attempts no rollback", async () => { - const write = txPool((text) => - text === "COMMIT" ? new Error("deadlock detected") : undefined, - ); - - await expect( - client(write).withTransaction(async () => "value"), - ).rejects.toMatchObject({ - code: "database/commit-failed", - cause: { message: "deadlock detected" }, - }); - - expect(write.statements).toStrictEqual(["BEGIN", "COMMIT"]); - expect(write.releases).toBe(1); - }); - - it("refuses a pool that cannot pin a connection", async () => { - // probePool has no connect(); spreading statements across arbitrary pooled connections would - // look like it worked, so this fails loudly instead. - await expect( - client(probePool()).withTransaction(async () => undefined), - ).rejects.toMatchObject({ code: "database/transactions-unsupported" }); - }); - - it("hands the callback a bare Queryable with no lifecycle surface", async () => { - const write = txPool(); - let received: Queryable | undefined; - await client(write).withTransaction(async (q) => { - received = q; - }); - - expect(received).toBeDefined(); - expect("release" in received!).toBe(false); - expect("end" in received!).toBe(false); - expect("connect" in received!).toBe(false); - }); - - it("honours a dialect-specific begin statement", async () => { - const write = txPool(); - await client(write, { beginStatement: "BEGIN IMMEDIATE" }).withTransaction( - async () => undefined, - ); - expect(write.statements[0]).toBe("BEGIN IMMEDIATE"); - }); - - it("instruments statements issued inside the transaction", async () => { - const write = txPool(); - const db = client(write); - const rows = await db.withTransaction((q) => db.query(q, "SELECT 1")); - expect(rows).toStrictEqual({ rows: [] }); - expect(write.statements).toStrictEqual(["BEGIN", "SELECT 1", "COMMIT"]); - }); - - describe("nesting", () => { - it("refuses to nest on the same client, and still releases the outer connection", async () => { - const write = txPool(); - const db = client(write); - - await expect( - db.withTransaction(async () => { - await db.withTransaction(async () => undefined); - }), - ).rejects.toMatchObject({ code: "database/nested-transaction" }); - - // The inner call never checked a connection out; the outer one rolled back and released. - expect(write).toMatchObject({ checkouts: 1, releases: 1 }); - expect(write.statements).toStrictEqual(["BEGIN", "ROLLBACK"]); - }); - - it("detects a nest that happens after an await", async () => { - const write = txPool(); - const db = client(write); - - await expect( - db.withTransaction(async (q) => { - await q.query("SELECT 1"); - await db.withTransaction(async () => undefined); - }), - ).rejects.toMatchObject({ code: "database/nested-transaction" }); - }); - - // The reason detection is async-context-scoped rather than a boolean on the client: two - // requests transacting at once are the normal case, not a nesting violation. - it("allows genuinely concurrent transactions on the same client", async () => { - const write = txPool(); - const db = client(write); - let releaseFirst!: () => void; - const gate = new Promise((resolve) => { - releaseFirst = resolve; - }); - - const first = db.withTransaction(async (q) => { - await gate; - await q.query("first"); - return 1; - }); - const second = db.withTransaction(async (q) => { - await q.query("second"); - releaseFirst(); - return 2; - }); - - await expect(Promise.all([first, second])).resolves.toStrictEqual([1, 2]); - expect(write).toMatchObject({ checkouts: 2, releases: 2 }); - }); - - // Two databases share no commit. Refusing this would not make the write atomic, it would only - // stop the caller expressing what they already have to reason about themselves. - it("allows a different client's transaction inside this one", async () => { - const outer = txPool(); - const inner = txPool(); - const outerDb = client(outer); - const innerDb = client(inner); - - await outerDb.withTransaction(async () => { - await innerDb.withTransaction(async (q) => { - await q.query("INSERT INTO other VALUES (1)"); - }); - }); - - expect(outer.statements).toStrictEqual(["BEGIN", "COMMIT"]); - expect(inner.statements).toStrictEqual([ - "BEGIN", - "INSERT INTO other VALUES (1)", - "COMMIT", - ]); - }); - }); -}); - -/** - * A driver stand-in wired through its real adapter, so the conformance suite below exercises the - * adapter's own checkout/release path rather than a hand-written pool. - */ -interface DriverProbe { - pool: QueryablePool; - statements: string[]; - /** Connections checked out but not yet released. */ - open: () => number; -} - -function pgProbe(): DriverProbe { - const statements: string[] = []; - let open = 0; - const pool = pgPool({ - query: (text) => { - statements.push(text); - return Promise.resolve({ rows: [], rowCount: null }); - }, - connect: () => { - open += 1; - return Promise.resolve({ - query: (text) => { - statements.push(text); - return Promise.resolve({ rows: [], rowCount: null }); - }, - release: () => { - open -= 1; - }, - }); - }, - end: () => Promise.resolve(), - }); - return { pool, statements, open: () => open }; -} - -function mysqlProbe(): DriverProbe { - const statements: string[] = []; - let open = 0; - const pool = mysqlPool({ - query: (sql) => { - statements.push(sql); - return Promise.resolve([[], undefined]); - }, - getConnection: () => { - open += 1; - return Promise.resolve({ - query: (sql) => { - statements.push(sql); - return Promise.resolve([[], undefined]); - }, - release: () => { - open -= 1; - }, - }); - }, - end: () => Promise.resolve(), - }); - return { pool, statements, open: () => open }; -} - -function sqliteProbe(): DriverProbe { - const statements: string[] = []; - const pool = sqlitePool( - { - prepare: (sql: string) => ({ - reader: sql.startsWith("SELECT"), - all: () => { - statements.push(sql); - return []; - }, - run: () => { - statements.push(sql); - return { changes: 0 }; - }, - }), - pragma: () => undefined, - close: () => undefined, - }, - { applyPragmas: false }, - ); - // better-sqlite3 has one connection; the adapter's checkout is a lock, not a pool slot, so - // there is no driver-side counter to read. Leak-freedom is asserted below by the fact that a - // hundred sequential transactions all complete rather than deadlocking on a leaked slot. - return { pool, statements, open: () => 0 }; -} - -/** - * Provider-agnostic transaction conformance. Running the same assertions through each adapter - * proves `withTransaction` is dialect-independent, and that every adapter's `connect()` really - * pins a connection and gives it back. - */ -describe.each([ - ["postgres", pgProbe], - ["mysql", mysqlProbe], - ["sqlite", sqliteProbe], -] as const)("withTransaction conformance: %s", (provider, makeProbe) => { - const db = (probe: DriverProbe): ReturnType => - provideDatabase({ provider, read: { database: "db" } }, { read: probe.pool }); - - it("commits the callback's statements", async () => { - const probe = makeProbe(); - await db(probe).withTransaction(async (q) => { - await q.query("INSERT INTO t VALUES (1)"); - }); - expect(probe.statements).toStrictEqual([ - "BEGIN", - "INSERT INTO t VALUES (1)", - "COMMIT", - ]); - expect(probe.open()).toBe(0); - }); - - it("rolls back and re-throws", async () => { - const probe = makeProbe(); - const boom = new Error("nope"); - await expect( - db(probe).withTransaction(async (q) => { - await q.query("INSERT INTO t VALUES (1)"); - throw boom; - }), - ).rejects.toBe(boom); - expect(probe.statements).toStrictEqual([ - "BEGIN", - "INSERT INTO t VALUES (1)", - "ROLLBACK", - ]); - expect(probe.open()).toBe(0); - }); - - it("holds nothing across a hundred sequential transactions", async () => { - const probe = makeProbe(); - const client = db(probe); - for (let i = 0; i < 100; i += 1) { - await client.withTransaction(async (q) => { - await q.query("SELECT 1"); - }); - } - expect(probe.open()).toBe(0); - expect(probe.statements).toHaveLength(300); - }); -}); - -// sqlite's semantics differ enough to be worth asserting rather than assuming: one connection, no -// nested transactions, and no error if a second BEGIN lands inside the first one's scope — it just -// silently joins it, and the first COMMIT commits both. -describe("sqlite transaction semantics", () => { - it("serializes concurrent transactions instead of interleaving them", async () => { - const probe = sqliteProbe(); - const client = provideDatabase( - { provider: "sqlite", read: { database: ":memory:" } }, - { read: probe.pool }, - ); - - const runs = ["a", "b", "c"].map((name) => - client.withTransaction(async (q) => { - await q.query(`INSERT INTO t VALUES ('${name}')`); - // Yield twice, so an unserialized implementation would certainly interleave here. - await Promise.resolve(); - await q.query(`UPDATE t SET n = 1 WHERE v = '${name}'`); - }), - ); - await Promise.all(runs); - - expect(probe.statements).toStrictEqual([ - "BEGIN", - "INSERT INTO t VALUES ('a')", - "UPDATE t SET n = 1 WHERE v = 'a'", - "COMMIT", - "BEGIN", - "INSERT INTO t VALUES ('b')", - "UPDATE t SET n = 1 WHERE v = 'b'", - "COMMIT", - "BEGIN", - "INSERT INTO t VALUES ('c')", - "UPDATE t SET n = 1 WHERE v = 'c'", - "COMMIT", - ]); - }); - - it("hands the lock back when a transaction fails, so the next one is not blocked", async () => { - const probe = sqliteProbe(); - const client = provideDatabase( - { provider: "sqlite", read: { database: ":memory:" } }, - { read: probe.pool }, - ); - - await expect( - client.withTransaction(() => Promise.reject(new Error("first failed"))), - ).rejects.toThrow("first failed"); - await expect( - client.withTransaction(async (q) => { - await q.query("SELECT 1"); - return "second ran"; - }), - ).resolves.toBe("second ran"); - }); -}); diff --git a/packages/database/src/database.ts b/packages/database/src/database.ts deleted file mode 100644 index 03da08c..0000000 --- a/packages/database/src/database.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { AsyncLocalStorage } from "node:async_hooks"; - -import { PlatformError } from "@primandproper/errors"; -import { - makeObserver, - type Logger, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { - DatabaseConfigSchema, - type DatabaseConfig, - type DatabaseConfigInput, -} from "./config.js"; - -const o11yName = "database"; - -function toError(value: unknown): Error { - return value instanceof Error ? value : new Error(String(value)); -} - -function defaultSleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -/** The normalized shape of a query result, regardless of driver. */ -export interface QueryResult { - /** The returned rows; empty for non-`SELECT` statements. */ - rows: unknown[]; - /** Rows affected, when the driver reports it. */ - rowCount?: number; -} - -/** - * Anything statements can be issued through: a pool, or the pinned connection a transaction runs - * on. It is deliberately the *whole* surface — no `end()`, no commit, no rollback — so a function - * written against `Queryable` composes with either, and holding one inside - * {@link DatabaseClient.withTransaction} grants no way to end the transaction or the pool. - */ -export interface Queryable { - query(text: string, params?: readonly unknown[]): Promise; -} - -/** - * A connection checked out of a pool and pinned for the life of a transaction. Returned by - * {@link QueryablePool.connect} and released by {@link DatabaseClient.withTransaction}, never by - * the caller — which is why it never reaches the transaction callback. - */ -export interface PooledConnection extends Queryable { - /** Returns the connection to its pool. Idempotent in every adapter here. */ - release(): void | Promise; -} - -/** - * The minimal pool surface this package instruments — the seam a query builder (Drizzle/Kysely) or - * raw SQL sits on. Driver pools are adapted to it by the functions in `adapters.ts`. - */ -export interface QueryablePool extends Queryable { - /** Drains and closes the pool. */ - end(): Promise; - /** - * Checks out a connection pinned to the caller. Optional because the seam is structural: a pool - * that cannot pin a connection cannot run a transaction, and {@link DatabaseClient.withTransaction} - * rejects with {@link TransactionsUnsupportedError} rather than silently spreading the statements - * across arbitrary pooled connections — which is the failure that looks like it worked. - */ - connect?(): Promise; -} - -/** - * An instrumented database client: a read pool, a write pool (the same instance when only one - * endpoint is configured), readiness probing, and lifecycle. The narrow analogue of platform-go's - * dialect `database.Client` — no query-executor inheritance, no migrations. - */ -export interface DatabaseClient { - readonly readPool: QueryablePool; - readonly writePool: QueryablePool; - /** Pings each distinct pool, retrying per the configured ping settings; `false` if any stays down. */ - isReady(): Promise; - /** - * Fail-fast variant of {@link isReady}: resolves when ready, otherwise rejects with a - * {@link DatabaseNotReadyError}. The underlying driver error (auth/host/TLS) is already logged at - * error level by the readiness probe. Use this at startup to abort instead of limping on. - */ - ensureReady(): Promise; - /** Closes each distinct pool. */ - close(): Promise; - /** The client's notion of "now", injectable for tests. */ - currentTime(): Date; - /** - * Runs a statement on `target` inside an observability span. Accepts the narrow {@link Queryable}, - * so statements issued inside {@link withTransaction} are instrumented the same way pool queries are. - */ - query( - target: Queryable, - text: string, - params?: readonly unknown[], - ): Promise; - /** - * Runs `fn` inside a single transaction on the **write** pool, on one pinned connection. - * Resolving commits; rejecting rolls back and re-throws the original error unchanged. - * - * `fn` receives a bare {@link Queryable} and nothing else: it cannot commit, roll back, close - * the pool, or outlive the closure. Lifecycle belongs to this method, which acquires the - * connection and releases it in a `finally` — including when the rollback itself fails. - * - * Nesting is **not** supported and throws {@link NestedTransactionError} rather than silently - * flattening two scopes into one (there are no savepoints). Detection is per async context, so - * genuinely concurrent transactions on the same client are unaffected, and nesting a *different* - * client's transaction inside this one is allowed — two databases have no shared commit and this - * package does not pretend otherwise. - * - * A failed commit throws {@link TransactionCommitError} and does not attempt a rollback: the - * driver has already ended the transaction and returned the connection, so a second attempt would - * only surface a spurious "no transaction in progress". - */ - withTransaction(fn: (q: Queryable) => Promise): Promise; -} - -/** Thrown when a client is used before its pools are reachable. */ -export class DatabaseNotReadyError extends PlatformError { - constructor() { - super("database/not-ready", "database is not ready"); - this.name = "DatabaseNotReadyError"; - } -} - -/** Thrown by {@link DatabaseClient.withTransaction} when the write pool cannot pin a connection. */ -export class TransactionsUnsupportedError extends PlatformError { - constructor(provider: string) { - super( - "database/transactions-unsupported", - `the ${provider} write pool does not expose connect(), so it cannot run a transaction`, - ); - this.name = "TransactionsUnsupportedError"; - } -} - -/** Thrown when {@link DatabaseClient.withTransaction} is called inside its own transaction. */ -export class NestedTransactionError extends PlatformError { - constructor() { - super( - "database/nested-transaction", - "withTransaction cannot be nested on the same client; savepoints are not supported", - ); - this.name = "NestedTransactionError"; - } -} - -/** - * Thrown when `COMMIT` fails. Distinct from an error thrown by the callback because the outcome is - * different in kind: the callback's work provably did not land, whereas a failed commit leaves the - * transaction's fate unknown to this process. The driver error is the `cause`. - */ -export class TransactionCommitError extends PlatformError { - constructor(cause: Error) { - super("database/commit-failed", "committing transaction", { cause }); - this.name = "TransactionCommitError"; - } -} - -/** - * The clients whose transactions are open in the current async context. Async-context-scoped rather - * than a field on the client, because a per-instance flag would call two concurrent requests a - * nesting violation — the common case — while still missing a genuine nest across an `await`. - */ -const openTransactions = new AsyncLocalStorage>(); - -/** Observability plus the injectable runtime knobs the client uses. */ -export interface DatabaseClientDeps extends ObservabilityDeps { - /** Overrides `Date` for {@link DatabaseClient.currentTime}; injectable for tests. */ - now?: () => Date; - /** Overrides the inter-ping delay; injectable so tests don't actually wait. */ - sleep?: (ms: number) => Promise; - /** The statement used to probe readiness. Defaults to `SELECT 1`. */ - pingQuery?: string; - /** - * The statement that opens a transaction. Defaults to `BEGIN`, which postgres, mysql, and sqlite - * all accept. Override it where the dialect's default locking mode is wrong for the workload — - * sqlite's `BEGIN IMMEDIATE` takes the write lock up front instead of failing with `SQLITE_BUSY` - * when a deferred transaction tries to upgrade mid-flight. - */ - beginStatement?: string; -} - -class InstrumentedClient implements DatabaseClient { - readonly readPool: QueryablePool; - readonly writePool: QueryablePool; - readonly #config: DatabaseConfig; - readonly #observer: Observer; - readonly #logger: Logger; - readonly #now: () => Date; - readonly #sleep: (ms: number) => Promise; - readonly #pingQuery: string; - readonly #beginStatement: string; - - constructor( - readPool: QueryablePool, - writePool: QueryablePool, - config: DatabaseConfig, - deps: DatabaseClientDeps = {}, - ) { - this.readPool = readPool; - this.writePool = writePool; - this.#config = config; - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#logger = this.#observer.logger(); - this.#now = deps.now ?? ((): Date => new Date()); - this.#sleep = deps.sleep ?? defaultSleep; - this.#pingQuery = deps.pingQuery ?? "SELECT 1"; - this.#beginStatement = deps.beginStatement ?? "BEGIN"; - } - - async isReady(): Promise { - const op = this.#observer.begin(`${o11yName}.isReady`); - try { - op.set("db.system", this.#config.provider) - .set("db.ping.max_attempts", this.#config.maxPingAttempts) - .set("db.ping.wait_period_ms", this.#config.pingWaitPeriodMs); - if (!(await this.#waitForPing(this.readPool, "read"))) return false; - if (this.writePool !== this.readPool) { - return await this.#waitForPing(this.writePool, "write"); - } - return true; - } finally { - op.end(); - } - } - - async ensureReady(): Promise { - if (!(await this.isReady())) { - throw new DatabaseNotReadyError(); - } - } - - async #waitForPing(pool: QueryablePool, name: string): Promise { - let attempt = 0; - for (;;) { - try { - await pool.query(this.#pingQuery); - return true; - } catch (err) { - if (attempt >= this.#config.maxPingAttempts) { - // A database that never comes ready is an error, not a debug footnote — and the driver - // error (auth failure, wrong host, TLS) is the diagnostic that matters, so surface it. - this.#logger.error(`ping failed for ${name} connection`, toError(err), { - connection: name, - attempts: attempt + 1, - }); - return false; - } - await this.#sleep(this.#config.pingWaitPeriodMs); - attempt += 1; - } - } - } - - async close(): Promise { - // Drain every distinct pool even if one rejects — a failed read-pool `end()` must not leak the - // write pool. Settle all, log each failure with its cause, then surface the failure(s). - const pools = - this.writePool === this.readPool - ? [this.readPool] - : [this.readPool, this.writePool]; - const results = await Promise.allSettled(pools.map((pool) => pool.end())); - const failures = results - .filter((r): r is PromiseRejectedResult => r.status === "rejected") - .map((r) => toError(r.reason)); - for (const err of failures) { - this.#logger.error("closing database pool", err); - } - const [first, ...rest] = failures; - if (first !== undefined) { - throw rest.length === 0 - ? first - : new AggregateError(failures, "closing database pools failed"); - } - } - - currentTime(): Date { - return this.#now(); - } - - async query( - target: Queryable, - text: string, - params?: readonly unknown[], - ): Promise { - const op = this.#observer.begin(`${o11yName}.query`); - try { - op.set("db.system", this.#config.provider).set("db.statement", text); - const result = await target.query(text, params); - op.set("db.rows", result.rows.length); - return result; - } catch (error) { - op.error(toError(error), "query failed"); - throw error; - } finally { - op.end(); - } - } - - async withTransaction(fn: (q: Queryable) => Promise): Promise { - const enclosing = openTransactions.getStore(); - if (enclosing?.has(this)) throw new NestedTransactionError(); - - // The write pool only. A transaction on the read pool targets a replica, where it either fails - // outright or reads a stale snapshot and discards every write in it. - const pool = this.writePool; - if (typeof pool.connect !== "function") { - throw new TransactionsUnsupportedError(this.#config.provider); - } - - const op = this.#observer.begin(`${o11yName}.withTransaction`); - op.set("db.system", this.#config.provider); - let connection: PooledConnection | undefined; - try { - connection = await pool.connect(); - await connection.query(this.#beginStatement); - const pinned = connection; - - let value: T; - try { - // Re-wrap rather than passing `pinned` straight through: the connection carries `release()`, - // and the callback holding an object it must not call is a weaker guarantee than it not - // holding one at all. - const scope = new Set(enclosing ?? []).add(this); - value = await openTransactions.run(scope, () => - fn({ query: (text, params) => pinned.query(text, params) }), - ); - } catch (error) { - await this.#rollback(pinned, toError(error)); - throw error; - } - - try { - await pinned.query("COMMIT"); - } catch (error) { - throw new TransactionCommitError(toError(error)); - } - return value; - } catch (error) { - op.error(toError(error), "transaction failed"); - throw error; - } finally { - // Release even when BEGIN, the callback, the rollback, or the commit failed — one connection - // leaked per transaction exhausts the pool, and presents as unrelated latency far from here. - if (connection !== undefined) await this.#release(connection); - op.end(); - } - } - - /** - * Rolls back, swallowing any failure. The error that caused the rollback is what the caller needs; - * replacing it with the cleanup failure would discard the diagnosis, so the cleanup failure is - * logged with its cause attached instead. - */ - async #rollback(connection: PooledConnection, cause: Error): Promise { - try { - await connection.query("ROLLBACK"); - } catch (err) { - this.#logger.error("rolling back transaction", toError(err), { - rollbackCause: cause.message, - }); - } - } - - /** Releases the connection, logging rather than throwing so it cannot mask the caller's error. */ - async #release(connection: PooledConnection): Promise { - try { - await connection.release(); - } catch (err) { - this.#logger.error("releasing transaction connection", toError(err)); - } - } -} - -/** The driver pools to instrument. `write` defaults to `read` for a single-endpoint setup. */ -export interface DatabasePools { - read: QueryablePool; - write?: QueryablePool; -} - -/** - * Wraps caller-provided driver pools in an instrumented {@link DatabaseClient}. The caller builds - * the pools from their driver of choice (see `adapters.ts`) using the connection strings derived - * from config — keeping ownership of the connection with the query layer, per the TS port's scope. - */ -export function provideDatabase( - config: DatabaseConfigInput, - pools: DatabasePools, - deps?: DatabaseClientDeps, -): DatabaseClient { - const cfg = DatabaseConfigSchema.parse(config); - return new InstrumentedClient(pools.read, pools.write ?? pools.read, cfg, deps); -} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts deleted file mode 100644 index 4a39f12..0000000 --- a/packages/database/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./config.js"; -export * from "./database.js"; -export * from "./adapters.js"; diff --git a/packages/database/tsconfig.json b/packages/database/tsconfig.json deleted file mode 100644 index bdb9327..0000000 --- a/packages/database/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022"], - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/database/tsup.config.ts b/packages/database/tsup.config.ts deleted file mode 100644 index 8bf2394..0000000 --- a/packages/database/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { index: "src/index.ts" }, - platform: "node", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: true, -}); diff --git a/packages/distributedlock/CHANGELOG.md b/packages/distributedlock/CHANGELOG.md deleted file mode 100644 index d0ba550..0000000 --- a/packages/distributedlock/CHANGELOG.md +++ /dev/null @@ -1,16 +0,0 @@ -# @primandproper/distributedlock - -## 0.1.0 - -### Minor Changes - -- a124406: Change `Lock.release()` and `Lock.refresh()` to return `Promise` (was `Promise`) and add a required `close()` to `DistributedLock`. Breaking for callers and implementers. Adds command/connect timeouts. - -### Patch Changes - -- Updated dependencies [a124406] -- Updated dependencies [a124406] -- Updated dependencies [a124406] - - @primandproper/database@0.1.0 - - @primandproper/errors@0.0.2 - - @primandproper/observability@0.1.0 diff --git a/packages/distributedlock/package.json b/packages/distributedlock/package.json deleted file mode 100644 index 4208777..0000000 --- a/packages/distributedlock/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@primandproper/distributedlock", - "version": "0.1.0", - "type": "module", - "description": "Server-only distributed locking: acquire/release/refresh with swappable providers.", - "license": "AGPL-3.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/primandproper/platform-ts.git", - "directory": "packages/distributedlock" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "eslint .", - "clean": "rm -rf dist .turbo" - }, - "dependencies": { - "@primandproper/database": "workspace:*", - "@primandproper/errors": "workspace:*", - "@primandproper/observability": "workspace:*", - "ioredis": "5.11.1", - "zod": "3.25.76" - } -} diff --git a/packages/distributedlock/src/config.ts b/packages/distributedlock/src/config.ts deleted file mode 100644 index 95e35a3..0000000 --- a/packages/distributedlock/src/config.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { z } from "zod"; - -/** Memory-provider config: an in-process lock manager scoped to one Node process. */ -export const MemoryDistributedLockConfigSchema = z.object({ - /** Default lease duration when {@link AcquireOptions.ttlMs} is omitted, in milliseconds. */ - defaultTtlMs: z.number().int().positive().default(30_000), -}); - -export type MemoryDistributedLockConfig = z.infer< - typeof MemoryDistributedLockConfigSchema ->; - -/** Redis-provider config: a single-node Redis reached via ioredis. */ -export const RedisDistributedLockConfigSchema = z.object({ - url: z.string().url(), - keyPrefix: z.string().default(""), - /** Default lease duration when {@link AcquireOptions.ttlMs} is omitted, in milliseconds. */ - defaultTtlMs: z.number().int().positive().default(30_000), - /** Reject a command that outlives this many ms — the fail-fast timeout knob. Off by default. */ - commandTimeoutMs: z.number().int().positive().optional(), - /** TCP connect timeout in ms. Defaults to ioredis's 10s. */ - connectTimeoutMs: z.number().int().positive().optional(), -}); - -export type RedisDistributedLockConfig = z.infer; - -/** - * Postgres-provider config: a lease table reached via a `@primandproper/database` pool (supplied at - * provide-time through {@link DistributedLockDeps.pool}, since a pool is a runtime value, not config). - */ -export const PostgresDistributedLockConfigSchema = z.object({ - /** The lease table name (a plain identifier). */ - table: z.string().default("distributed_locks"), - /** Default lease duration when {@link AcquireOptions.ttlMs} is omitted, in milliseconds. */ - defaultTtlMs: z.number().int().positive().default(30_000), -}); - -export type PostgresDistributedLockConfig = z.infer< - typeof PostgresDistributedLockConfigSchema ->; - -/** - * Distributed-lock config. Replaces the Go `env:`-tagged struct + ozzo `ValidateWithContext`. - * `memory` is a correct single-process lock; `noop` always grants and never blocks; `redis` - * (single-node `SET NX PX` + compare-and-delete) and `postgres` (a token+expiry lease table) give - * cross-process exclusion and stay server-side. The `postgres` provider also needs a DB pool passed - * via {@link DistributedLockDeps.pool}. - */ -export const DistributedLockConfigSchema = z - .object({ - provider: z.enum(["memory", "redis", "postgres", "noop"]).default("memory"), - memory: MemoryDistributedLockConfigSchema.optional(), - redis: RedisDistributedLockConfigSchema.optional(), - postgres: PostgresDistributedLockConfigSchema.optional(), - }) - .superRefine((cfg, ctx) => { - if (cfg.provider === "redis" && cfg.redis === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["redis"], - message: "redis config is required when provider is 'redis'", - }); - } - }); - -export type DistributedLockConfig = z.infer; -export type DistributedLockConfigInput = z.input; diff --git a/packages/distributedlock/src/distributedlock.test.ts b/packages/distributedlock/src/distributedlock.test.ts deleted file mode 100644 index 6ff1e55..0000000 --- a/packages/distributedlock/src/distributedlock.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import { makeRecordingObserver } from "@primandproper/observability"; -import { describe, expect, it } from "vitest"; - -import { MemoryDistributedLock } from "./providers/memory.js"; -import { NoopDistributedLock } from "./providers/noop.js"; -import { PostgresDistributedLock } from "./providers/postgres.node.js"; -import { RedisDistributedLock } from "./providers/redis.node.js"; - -import { provideDistributedLock, type DistributedLock } from "./index.js"; - -/** - * Live-Redis integration is opt-in: set DISTRIBUTEDLOCK_TEST_REDIS_URL to a reachable Redis - * (e.g. redis://localhost:6379) to run the conformance + cross-process suites against it. The - * default offline run skips them and stays green. Each instance gets a unique key prefix so - * concurrent/leftover keys never collide on a shared server. - */ -const REDIS_URL = process.env.DISTRIBUTEDLOCK_TEST_REDIS_URL; - -/** A controllable clock for deterministic ttl/expiry tests. */ -function fakeClock(start = 0): { now: () => number; advance: (ms: number) => void } { - let t = start; - return { - now: () => t, - advance: (ms) => { - t += ms; - }, - }; -} - -/** - * Provider-agnostic conformance suite. Running the same assertions against multiple providers - * proves the `DistributedLock` interface is implementation-independent. - */ -function conformance(name: string, make: () => DistributedLock): void { - describe(name, () => { - it("acquires a free key", async () => { - const lock = await make().acquire("job"); - expect(lock).toBeDefined(); - expect(lock?.key).toBe("job"); - }); - - it("release of an owned lease reports true", async () => { - const lock = await make().acquire("job"); - await expect(lock?.release()).resolves.toBe(true); - }); - - it("refresh of an owned lease reports true", async () => { - const lock = await make().acquire("job"); - await expect(lock?.refresh()).resolves.toBe(true); - }); - - it("pings without throwing", async () => { - await expect(make().ping()).resolves.toBeUndefined(); - }); - - it("closes without throwing", async () => { - await expect(make().close()).resolves.toBeUndefined(); - }); - }); -} - -conformance("MemoryDistributedLock", () => new MemoryDistributedLock()); -conformance("NoopDistributedLock", () => new NoopDistributedLock()); - -const redisLock = (): RedisDistributedLock => - new RedisDistributedLock({ - url: REDIS_URL ?? "redis://localhost:6379", - keyPrefix: `dltest:${randomUUID()}:`, - }); - -describe.skipIf(!REDIS_URL)("RedisDistributedLock (live)", () => { - conformance("RedisDistributedLock", redisLock); - - it("grants a contended key undefined while held", async () => { - const dl = redisLock(); - const first = await dl.acquire("job", { ttlMs: 5_000 }); - expect(first).toBeDefined(); - expect(await dl.acquire("job", { ttlMs: 5_000 })).toBeUndefined(); - }); - - it("frees the key on release so it can be re-acquired", async () => { - const dl = redisLock(); - const first = await dl.acquire("job", { ttlMs: 5_000 }); - expect(first).toBeDefined(); - - await first?.release(); - expect(await dl.acquire("job", { ttlMs: 5_000 })).toBeDefined(); - }); - - it("does not free a key whose lease was taken over", async () => { - const dl = redisLock(); - const stale = await dl.acquire("job", { ttlMs: 200 }); - expect(stale).toBeDefined(); - - await new Promise((r) => setTimeout(r, 300)); // stale's lease expires - const fresh = await dl.acquire("job", { ttlMs: 5_000 }); - expect(fresh).toBeDefined(); - - expect(await stale?.release()).toBe(false); // reports the loss; must not free fresh's lease - expect(await dl.acquire("job", { ttlMs: 5_000 })).toBeUndefined(); - }); -}); - -describe("MemoryDistributedLock", () => { - it("grants a second acquire of the same key undefined while held", async () => { - const dl = new MemoryDistributedLock(); - const first = await dl.acquire("job"); - expect(first).toBeDefined(); - expect(await dl.acquire("job")).toBeUndefined(); - }); - - it("frees the key on release so it can be re-acquired", async () => { - const dl = new MemoryDistributedLock(); - const first = await dl.acquire("job"); - expect(first).toBeDefined(); - - await first?.release(); - expect(await dl.acquire("job")).toBeDefined(); - }); - - it("auto-expires a lease after its ttl", async () => { - const clock = fakeClock(); - const dl = new MemoryDistributedLock({}, { now: clock.now }); - - const first = await dl.acquire("job", { ttlMs: 1_000 }); - expect(first).toBeDefined(); - expect(await dl.acquire("job")).toBeUndefined(); - - clock.advance(999); - expect(await dl.acquire("job")).toBeUndefined(); - - clock.advance(1); // total 1000ms == ttl - expect(await dl.acquire("job")).toBeDefined(); - }); - - it("refresh extends the lease past its original expiry", async () => { - const clock = fakeClock(); - const dl = new MemoryDistributedLock({}, { now: clock.now }); - - const lock = await dl.acquire("job", { ttlMs: 1_000 }); - expect(lock).toBeDefined(); - - clock.advance(900); - await lock?.refresh(1_000); // expiry now at 1900 - - clock.advance(200); // total 1100ms; original ttl would have lapsed - expect(await dl.acquire("job")).toBeUndefined(); - }); - - it("does not free someone else's lock after losing the lease", async () => { - const clock = fakeClock(); - const dl = new MemoryDistributedLock({}, { now: clock.now }); - - const stale = await dl.acquire("job", { ttlMs: 1_000 }); - expect(stale).toBeDefined(); - - clock.advance(1_000); // stale's lease expires - const fresh = await dl.acquire("job", { ttlMs: 1_000 }); - expect(fresh).toBeDefined(); - - expect(await stale?.release()).toBe(false); // reports the loss; must not free fresh's lease - expect(await dl.acquire("job")).toBeUndefined(); - }); - - it("does not refresh someone else's lock after losing the lease", async () => { - const clock = fakeClock(); - const dl = new MemoryDistributedLock({}, { now: clock.now }); - - const stale = await dl.acquire("job", { ttlMs: 1_000 }); - clock.advance(1_000); - const fresh = await dl.acquire("job", { ttlMs: 1_000 }); - expect(fresh).toBeDefined(); - - expect(await stale?.refresh(10_000)).toBe(false); // reports the loss; must not touch fresh's lease - - clock.advance(1_000); // fresh's own lease lapses - expect(await dl.acquire("job")).toBeDefined(); - }); - - // DL-1: refresh must not revive a lease that lapsed on the clock, even with no takeover. - it("refresh reports loss and does not revive an expired-but-untaken lease", async () => { - const clock = fakeClock(); - const dl = new MemoryDistributedLock({}, { now: clock.now }); - - const lock = await dl.acquire("job", { ttlMs: 1_000 }); - expect(lock).toBeDefined(); - - clock.advance(1_000); // lease lapses; nobody else has taken it - expect(await lock?.refresh()).toBe(false); - - // The key is free (refresh did not resurrect the dead lease), so a fresh acquire succeeds. - expect(await dl.acquire("job")).toBeDefined(); - }); - - // DL-2: an abandoned expired lease must not linger in the map forever — a later acquire sweeps it. - it("sweeps abandoned expired leases on a subsequent acquire", async () => { - const clock = fakeClock(); - const observer = makeRecordingObserver(); - const dl = new MemoryDistributedLock({}, { now: clock.now, observer }); - - // Acquire and abandon a key (never released), then let its lease lapse. - expect(await dl.acquire("abandoned", { ttlMs: 1_000 })).toBeDefined(); - clock.advance(1_000); - - // Acquiring an unrelated key triggers the opportunistic sweep, dropping the dead "abandoned" row. - observer.reset(); - expect(await dl.acquire("other", { ttlMs: 1_000 })).toBeDefined(); - expect(observer.data()["leases.swept"]).toBe(1); - }); -}); - -describe("observability (INST-2)", () => { - it("runs acquire/release/refresh inside the injected observer, keyed by the lock key", async () => { - const observer = makeRecordingObserver(); - const dl = new MemoryDistributedLock({}, { observer }); - - const lock = await dl.acquire("job"); - expect(lock).toBeDefined(); - await lock?.refresh(); - await lock?.release(); - - // The factory-style spans are named and each carries the key on both pillars. - expect(observer.runs.map((r) => r.operation)).toEqual([ - "acquire", - "refresh", - "release", - ]); - for (const op of ["acquire", "refresh", "release"]) { - const keyObs = observer.forOperation(op).find((o) => o.key === "key"); - expect(keyObs, `${op} must name the key`).toBeDefined(); - expect(keyObs?.value).toBe("job"); - expect(keyObs?.pillar).toBe("both"); // fans to span AND log - } - }); - - it("names the key when acquire is contended", async () => { - const observer = makeRecordingObserver(); - const dl = new MemoryDistributedLock({}, { observer }); - - expect(await dl.acquire("job")).toBeDefined(); - expect(await dl.acquire("job")).toBeUndefined(); // contended - - const contendedAcquire = observer - .forOperation("acquire") - .filter((o) => o.key === "key" && o.value === "job"); - expect(contendedAcquire.length).toBe(2); // both attempts named the key - }); - - it("names the key when release/refresh find the lease lost", async () => { - const clock = fakeClock(); - const observer = makeRecordingObserver(); - const dl = new MemoryDistributedLock({}, { now: clock.now, observer }); - - const stale = await dl.acquire("job", { ttlMs: 1_000 }); - clock.advance(1_000); // lease lapses - await dl.acquire("job", { ttlMs: 1_000 }); // taken over - - expect(await stale?.release()).toBe(false); - expect(await stale?.refresh()).toBe(false); - - for (const op of ["release", "refresh"]) { - const keyObs = observer.forOperation(op).find((o) => o.key === "key"); - expect(keyObs?.value).toBe("job"); - } - }); - - it("provideDistributedLock threads an injected observer to the provider", async () => { - const observer = makeRecordingObserver(); - const dl = provideDistributedLock({ provider: "memory" }, { observer }); - - await dl.acquire("job"); - - expect(observer.forOperation("acquire").some((o) => o.key === "key")).toBe(true); - }); -}); - -describe("provideDistributedLock", () => { - it("defaults to the memory provider", () => { - expect(provideDistributedLock()).toBeInstanceOf(MemoryDistributedLock); - }); - - it("builds a noop lock when requested", () => { - expect(provideDistributedLock({ provider: "noop" })).toBeInstanceOf( - NoopDistributedLock, - ); - }); - - it("builds a redis lock when configured", () => { - expect( - provideDistributedLock({ - provider: "redis", - redis: { url: "redis://localhost:6379" }, - }), - ).toBeInstanceOf(RedisDistributedLock); - }); - - it("rejects redis provider without redis config", () => { - expect(() => provideDistributedLock({ provider: "redis" })).toThrow(); - }); - - it("noop always grants, even for a contended key", async () => { - const dl = provideDistributedLock({ provider: "noop" }); - expect(await dl.acquire("job")).toBeDefined(); - expect(await dl.acquire("job")).toBeDefined(); - }); - - it("applies the configured default ttl", async () => { - const clock = fakeClock(); - const dl = provideDistributedLock( - { provider: "memory", memory: { defaultTtlMs: 1_000 } }, - { now: clock.now }, - ); - - expect(await dl.acquire("job")).toBeDefined(); - expect(await dl.acquire("job")).toBeUndefined(); - - clock.advance(1_000); - expect(await dl.acquire("job")).toBeDefined(); - }); -}); - -// DL-2: the postgres provider reclaims abandoned expired rows via a maintenance call. (The full -// acquire/release lifecycle against a live postgres is exercised separately, behind an env flag.) -describe("PostgresDistributedLock.cleanupExpired (DL-2)", () => { - it("deletes rows whose lease has lapsed and returns the count", async () => { - const queries: string[] = []; - const pool = { - query: (text: string) => { - queries.push(text); - return Promise.resolve({ rows: [], rowCount: 3 }); - }, - end: () => Promise.resolve(), - }; - const dl = new PostgresDistributedLock({ pool }); - - await expect(dl.cleanupExpired()).resolves.toBe(3); - expect(queries).toHaveLength(1); - expect(queries[0]).toMatch( - /DELETE FROM distributed_locks WHERE expires_at < now\(\)/, - ); - }); -}); diff --git a/packages/distributedlock/src/distributedlock.ts b/packages/distributedlock/src/distributedlock.ts deleted file mode 100644 index d3f808c..0000000 --- a/packages/distributedlock/src/distributedlock.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * A held distributed lock. Returned by {@link DistributedLock.acquire} on a successful - * acquisition; release it when done, or refresh it to extend the lease before it expires. - */ -export interface Lock { - /** The key this lock guards. */ - readonly key: string; - /** - * Releases the lock, freeing the lease only if this caller still owns it. Resolves `true` when - * it freed an owned lease, `false` when the lease had already expired or been taken over by - * another holder — a lost lease is reported, not silently swallowed, so a caller can tell it - * was doing "exclusive" work it no longer had the right to. - */ - release(): Promise; - /** - * Extends the lease, resetting its expiry to `ttlMs` from now (or the lock's original ttl - * when omitted). Resolves `true` when the still-owned lease was extended, `false` when the - * lease had expired or been taken over — a refresh loop should treat `false` as "lease lost, - * stop the exclusive work". - */ - refresh(ttlMs?: number): Promise; -} - -/** Options for a single {@link DistributedLock.acquire} call. */ -export interface AcquireOptions { - /** How long the lease is held before it auto-expires, in milliseconds. */ - ttlMs?: number; -} - -/** - * A distributed mutual-exclusion lock. Contention is `undefined`, not a sentinel error — the - * same idiomatic-TypeScript divergence from Go's `(value, error)` the cache makes for a miss. - * Callers branch on the optional rather than catching a "lock held" exception. - */ -export interface DistributedLock { - /** - * Attempts to acquire the lock for `key`. Returns a {@link Lock} on success, or `undefined` - * when the key is currently held by someone else (do not retry-on-throw; branch on the - * optional). Leases auto-expire after their ttl, so a stale holder never deadlocks the key. - */ - acquire(key: string, opts?: AcquireOptions): Promise; - /** Verifies the backing store is reachable. */ - ping(): Promise; - /** - * Releases any resources the lock manager holds (e.g. a Redis connection) so the process can - * exit gracefully. Providers that hold nothing resolve immediately; the postgres provider leaves - * its injected pool open for its owner. Idempotent — safe to call more than once. Held - * {@link Lock}s are not released by this; release or let them expire before closing. - */ - close(): Promise; -} diff --git a/packages/distributedlock/src/index.ts b/packages/distributedlock/src/index.ts deleted file mode 100644 index 59068f9..0000000 --- a/packages/distributedlock/src/index.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { QueryablePool } from "@primandproper/database"; -import type { ObservabilityDeps } from "@primandproper/observability"; - -import { - DistributedLockConfigSchema, - type DistributedLockConfigInput, -} from "./config.js"; -import type { DistributedLock } from "./distributedlock.js"; -import { - MemoryDistributedLock, - type MemoryDistributedLockDeps, -} from "./providers/memory.js"; -import { NoopDistributedLock } from "./providers/noop.js"; -import { PostgresDistributedLock } from "./providers/postgres.node.js"; -import { RedisDistributedLock } from "./providers/redis.node.js"; - -export * from "./distributedlock.js"; -export * from "./config.js"; -export { - MemoryDistributedLock, - type MemoryDistributedLockDeps, - type MemoryDistributedLockOptions, -} from "./providers/memory.js"; -export { NoopDistributedLock } from "./providers/noop.js"; -export { - PostgresDistributedLock, - type PostgresDistributedLockOptions, -} from "./providers/postgres.node.js"; -export { - RedisDistributedLock, - type RedisDistributedLockOptions, -} from "./providers/redis.node.js"; - -/** - * Deps accepted by {@link provideDistributedLock}: observability, an injectable clock, and — for the - * `postgres` provider — the database pool the lease table lives in. - */ -export interface DistributedLockDeps extends ObservabilityDeps { - now?: () => number; - /** Required when `provider` is `postgres`: the pool the lock table is read/written through. */ - pool?: QueryablePool; -} - -/** - * Validates config (applying defaults) and returns the matching {@link DistributedLock}. - * Mirrors the Go platform's `ProvideDistributedLock`. Supports `memory` (default), `redis`, - * and `noop`. - */ -export function provideDistributedLock( - config?: DistributedLockConfigInput, - deps?: DistributedLockDeps, -): DistributedLock { - const cfg = DistributedLockConfigSchema.parse(config ?? {}); - const memoryDeps: MemoryDistributedLockDeps = {}; - if (deps?.logger !== undefined) { - memoryDeps.logger = deps.logger; - } - if (deps?.tracer !== undefined) { - memoryDeps.tracer = deps.tracer; - } - if (deps?.metrics !== undefined) { - memoryDeps.metrics = deps.metrics; - } - if (deps?.observer !== undefined) { - memoryDeps.observer = deps.observer; - } - if (deps?.now !== undefined) { - memoryDeps.now = deps.now; - } - - switch (cfg.provider) { - case "memory": - return new MemoryDistributedLock( - cfg.memory ? { defaultTtlMs: cfg.memory.defaultTtlMs } : {}, - memoryDeps, - ); - case "redis": - // superRefine guarantees this, but narrow for the type checker. - if (cfg.redis === undefined) { - throw new Error("redis config is required when provider is 'redis'"); - } - return new RedisDistributedLock( - { - url: cfg.redis.url, - keyPrefix: cfg.redis.keyPrefix, - defaultTtlMs: cfg.redis.defaultTtlMs, - ...(cfg.redis.commandTimeoutMs !== undefined - ? { commandTimeoutMs: cfg.redis.commandTimeoutMs } - : {}), - ...(cfg.redis.connectTimeoutMs !== undefined - ? { connectTimeoutMs: cfg.redis.connectTimeoutMs } - : {}), - }, - memoryDeps, - ); - case "postgres": { - if (deps?.pool === undefined) { - throw new Error("a `pool` dep is required when provider is 'postgres'"); - } - const pg = cfg.postgres; - return new PostgresDistributedLock( - { - pool: deps.pool, - ...(pg?.table !== undefined ? { table: pg.table } : {}), - ...(pg?.defaultTtlMs !== undefined ? { defaultTtlMs: pg.defaultTtlMs } : {}), - }, - memoryDeps, - ); - } - case "noop": - return new NoopDistributedLock(); - } -} diff --git a/packages/distributedlock/src/postgres.test.ts b/packages/distributedlock/src/postgres.test.ts deleted file mode 100644 index 595c6e5..0000000 --- a/packages/distributedlock/src/postgres.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { QueryResult, QueryablePool } from "@primandproper/database"; -import { describe, expect, it } from "vitest"; - -import { PostgresDistributedLock } from "./index.js"; - -interface Call { - text: string; - params: readonly unknown[] | undefined; -} - -type FakePool = QueryablePool & { calls: Call[] }; - -function fakePool(responder: (text: string) => QueryResult): FakePool { - const calls: Call[] = []; - return { - calls, - query(text, params): Promise { - calls.push({ text, params }); - return Promise.resolve(responder(text)); - }, - end: () => Promise.resolve(), - }; -} - -/** Grants acquisition (INSERT returns a row) and reports one affected row for writes. */ -function granting(text: string): QueryResult { - if (text.startsWith("INSERT")) return { rows: [{ token: "t" }] }; - if (text.startsWith("UPDATE")) return { rows: [{ lock_key: "job" }] }; - if (text.startsWith("DELETE")) return { rows: [], rowCount: 1 }; - return { rows: [{ ok: 1 }] }; -} - -const callWith = (pool: FakePool, keyword: string): Call | undefined => - pool.calls.find((c) => c.text.startsWith(keyword)); - -describe("PostgresDistributedLock", () => { - it("acquires by upsert and threads the token through release", async () => { - const pool = fakePool(granting); - const lock = new PostgresDistributedLock({ pool }); - - const held = await lock.acquire("job", { ttlMs: 5000 }); - expect(held).toBeDefined(); - - const insert = callWith(pool, "INSERT"); - expect(insert?.params?.[0]).toBe("job"); - expect(insert?.params?.[2]).toBe(5000); - const token = insert?.params?.[1]; - expect(typeof token).toBe("string"); - - await held?.release(); - const del = callWith(pool, "DELETE"); - expect(del?.params).toStrictEqual(["job", token]); - }); - - it("returns undefined under contention", async () => { - const pool = fakePool((text) => - text.startsWith("INSERT") ? { rows: [] } : { rows: [] }, - ); - const lock = new PostgresDistributedLock({ pool }); - await expect(lock.acquire("job")).resolves.toBeUndefined(); - }); - - it("refreshes with the same token and a new ttl", async () => { - const pool = fakePool(granting); - const lock = new PostgresDistributedLock({ pool }); - const held = await lock.acquire("job", { ttlMs: 1000 }); - const token = callWith(pool, "INSERT")?.params?.[1]; - - await held?.refresh(2000); - const update = callWith(pool, "UPDATE"); - expect(update?.params).toStrictEqual(["job", token, 2000]); - }); - - it("falls back to the default ttl when none is given", async () => { - const pool = fakePool(granting); - await new PostgresDistributedLock({ pool, defaultTtlMs: 12345 }).acquire("job"); - expect(callWith(pool, "INSERT")?.params?.[2]).toBe(12345); - }); - - it("pings with SELECT 1", async () => { - const pool = fakePool(granting); - await new PostgresDistributedLock({ pool }).ping(); - expect(pool.calls.some((c) => c.text === "SELECT 1")).toBe(true); - }); - - it("creates the lock table via ensureSchema", async () => { - const pool = fakePool(granting); - await new PostgresDistributedLock({ pool, table: "locks" }).ensureSchema(); - expect(callWith(pool, "CREATE TABLE IF NOT EXISTS locks")).toBeDefined(); - }); - - it("rejects an unsafe table name", () => { - const pool = fakePool(granting); - expect( - () => new PostgresDistributedLock({ pool, table: "locks; DROP TABLE x" }), - ).toThrow(); - }); -}); diff --git a/packages/distributedlock/src/providers/memory.ts b/packages/distributedlock/src/providers/memory.ts deleted file mode 100644 index 5491042..0000000 --- a/packages/distributedlock/src/providers/memory.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import type { AcquireOptions, DistributedLock, Lock } from "../distributedlock.js"; - -import { lockInstruments, type LockInstruments } from "./support.js"; - -const o11yName = "distributedlock"; - -/** Injectable clock + observability. `now` is overridable for deterministic tests. */ -export interface MemoryDistributedLockDeps extends ObservabilityDeps { - now?: () => number; -} - -export interface MemoryDistributedLockOptions { - /** Lease duration when {@link AcquireOptions.ttlMs} is omitted, in milliseconds. */ - defaultTtlMs?: number; -} - -interface Lease { - token: number; - expiresAt: number; -} - -const DEFAULT_TTL_MS = 30_000; - -/** - * A correct in-process distributed lock: a {@link Map} of key to its current lease. A key may - * be acquired iff it is free or its lease has expired against the injected clock. Each grant - * stamps a unique token, so a {@link Lock} only frees the lease it still owns — a holder that - * lost its lease (expired, then re-acquired by another caller) cannot release or refresh the - * new holder's lock. Single-process mutual exclusion only; not cross-process. - */ -export class MemoryDistributedLock implements DistributedLock { - readonly #defaultTtlMs: number; - readonly #now: () => number; - readonly #observer: Observer; - readonly #instruments: LockInstruments; - readonly #leases = new Map(); - - #nextToken = 1; - - constructor( - options: MemoryDistributedLockOptions = {}, - deps: MemoryDistributedLockDeps = {}, - ) { - this.#defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS; - this.#now = deps.now ?? (() => Date.now()); - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#instruments = lockInstruments(o11yName, deps); - } - - acquire(key: string, opts: AcquireOptions = {}): Promise { - return this.#observer.run("acquire", (op) => { - op.set("key", key); - // DL-2: opportunistically drop every lapsed lease so a key that was acquired once and then - // abandoned (expired, never re-acquired) doesn't linger in the map forever. Cheap in-process - // (the lock set is small) and safe — an expired lease is dead. - const swept = this.#sweepExpired(); - if (swept > 0) op.set("leases.swept", swept); - const existing = this.#leases.get(key); - if (existing !== undefined && existing.expiresAt > this.#now()) { - op.logger().debug("lock is already held"); - this.#instruments.contention.add(1, { operation: "acquire" }); - return undefined; - } - - const ttlMs = opts.ttlMs ?? this.#defaultTtlMs; - const token = this.#nextToken++; - this.#leases.set(key, { token, expiresAt: this.#now() + ttlMs }); - return this.#makeLock(key, token, ttlMs); - }); - } - - ping(): Promise { - return Promise.resolve(); - } - - close(): Promise { - return Promise.resolve(); - } - - /** Drops every lease whose expiry has passed against the injected clock; returns how many. */ - #sweepExpired(): number { - const now = this.#now(); - let swept = 0; - for (const [key, lease] of this.#leases) { - if (lease.expiresAt <= now) { - this.#leases.delete(key); - swept += 1; - } - } - return swept; - } - - #owns(key: string, token: number): boolean { - const lease = this.#leases.get(key); - return lease?.token === token && lease.expiresAt > this.#now(); - } - - #makeLock(key: string, token: number, ttlMs: number): Lock { - const release = (): Promise => - this.#observer.run("release", (op) => { - op.set("key", key); - if (this.#owns(key, token)) { - this.#leases.delete(key); - return true; - } - op.logger().debug("release ignored: lease no longer owned"); - this.#instruments.contention.add(1, { operation: "release" }); - return false; - }); - - const refresh = (newTtlMs?: number): Promise => - this.#observer.run("refresh", (op) => { - op.set("key", key); - // Guard on #owns (token AND unexpired) so refresh cannot revive a lapsed lease, matching - // the redis/postgres providers, whose stores drop expired leases on their own clocks. - if (this.#owns(key, token)) { - const lease = this.#leases.get(key); - if (lease !== undefined) { - lease.expiresAt = this.#now() + (newTtlMs ?? ttlMs); - } - return true; - } - op.logger().debug("refresh ignored: lease no longer owned"); - this.#instruments.contention.add(1, { operation: "refresh" }); - return false; - }); - - return { key, release, refresh }; - } -} diff --git a/packages/distributedlock/src/providers/noop.ts b/packages/distributedlock/src/providers/noop.ts deleted file mode 100644 index 76925b3..0000000 --- a/packages/distributedlock/src/providers/noop.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { DistributedLock, Lock } from "../distributedlock.js"; - -/** - * A {@link DistributedLock} that grants every acquisition immediately and never blocks; - * release and refresh are no-ops. Useful for single-instance deployments and tests where - * mutual exclusion is unnecessary. Provides no actual exclusion — do not rely on it for safety. - */ -export class NoopDistributedLock implements DistributedLock { - acquire(key: string): Promise { - return Promise.resolve({ - key, - release: () => Promise.resolve(true), - refresh: () => Promise.resolve(true), - }); - } - - ping(): Promise { - return Promise.resolve(); - } - - close(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/distributedlock/src/providers/postgres.node.ts b/packages/distributedlock/src/providers/postgres.node.ts deleted file mode 100644 index 734ca80..0000000 --- a/packages/distributedlock/src/providers/postgres.node.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import type { QueryablePool } from "@primandproper/database"; -import { wrap } from "@primandproper/errors"; -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import type { AcquireOptions, DistributedLock, Lock } from "../distributedlock.js"; - -import { lockInstruments, type LockInstruments } from "./support.js"; - -const o11yName = "distributedlock"; -const DEFAULT_TTL_MS = 30_000; -const DEFAULT_TABLE = "distributed_locks"; -// Table names cannot be parameterized, so restrict to a plain identifier to keep SQL injection-safe. -const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; - -export interface PostgresDistributedLockOptions { - /** A pool from `@primandproper/database` (or any {@link QueryablePool}) over the lock database. */ - pool: QueryablePool; - /** The lock table name (a plain identifier). Defaults to `distributed_locks`. */ - table?: string; - /** Lease duration when {@link AcquireOptions.ttlMs} is omitted, in milliseconds. */ - defaultTtlMs?: number; -} - -/** - * Node-only provider backed by a Postgres lock table. Each key is a row carrying the holder's - * `token` and an `expires_at`; the database's own clock decides expiry, mirroring how the redis - * provider leans on redis's clock. Acquisition is an atomic upsert that only takes over a row whose - * lease has lapsed; release and refresh are token-guarded, so a {@link Lock} only ever frees or - * extends a lease this caller still owns. Contention is `undefined`, not a throw. - * - * This deliberately uses a lease table rather than `pg_advisory_lock`: advisory locks are bound to a - * single session and have no TTL, so they cannot honor this interface's ttl/refresh semantics over a - * connection pool. - */ -export class PostgresDistributedLock implements DistributedLock { - readonly #pool: QueryablePool; - readonly #table: string; - readonly #defaultTtlMs: number; - readonly #observer: Observer; - readonly #instruments: LockInstruments; - readonly #acquireSql: string; - readonly #releaseSql: string; - readonly #refreshSql: string; - - constructor(options: PostgresDistributedLockOptions, deps: ObservabilityDeps = {}) { - const table = options.table ?? DEFAULT_TABLE; - if (!SAFE_IDENTIFIER.test(table)) { - throw new Error(`invalid lock table name: ${table}`); - } - this.#pool = options.pool; - this.#table = table; - this.#defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS; - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#instruments = lockInstruments(o11yName, deps); - this.#acquireSql = - `INSERT INTO ${table} (lock_key, token, expires_at) ` + - `VALUES ($1, $2, now() + $3 * interval '1 millisecond') ` + - `ON CONFLICT (lock_key) DO UPDATE SET token = EXCLUDED.token, expires_at = EXCLUDED.expires_at ` + - `WHERE ${table}.expires_at < now() RETURNING token`; - this.#releaseSql = `DELETE FROM ${table} WHERE lock_key = $1 AND token = $2`; - this.#refreshSql = - `UPDATE ${table} SET expires_at = now() + $3 * interval '1 millisecond' ` + - `WHERE lock_key = $1 AND token = $2 AND expires_at > now() RETURNING lock_key`; - } - - /** Creates the lock table if it does not exist. Run once at startup. */ - async ensureSchema(): Promise { - await this.#pool.query( - `CREATE TABLE IF NOT EXISTS ${this.#table} ` + - `(lock_key text PRIMARY KEY, token text NOT NULL, expires_at timestamptz NOT NULL)`, - ); - } - - /** - * DL-2: deletes every row whose lease has lapsed and returns the count. `acquire` already - * reclaims a *contended* key's expired row via its upsert, but a key that is acquired once and - * then abandoned leaves a dead row forever — schedule this periodically (e.g. a cron/interval) - * to reclaim that residue. Kept a caller-driven maintenance call rather than a per-`acquire` - * full-table `DELETE`, which would add a table scan to the hot path. - */ - async cleanupExpired(): Promise { - return this.#observer.run("cleanup_expired", async (op) => { - try { - const result = await this.#pool.query( - `DELETE FROM ${this.#table} WHERE expires_at < now()`, - ); - const deleted = result.rowCount ?? 0; - op.set("deleted", deleted); - return deleted; - } catch (err) { - throw op.error( - wrap("distributedlock: failed to clean up expired locks on postgres", err), - "cleaning up expired locks on postgres", - ); - } - }); - } - - acquire(key: string, opts: AcquireOptions = {}): Promise { - return this.#observer.run("acquire", async (op) => { - op.set("key", key); - const ttlMs = opts.ttlMs ?? this.#defaultTtlMs; - const token = randomUUID(); - - let rows: unknown[]; - try { - rows = (await this.#pool.query(this.#acquireSql, [key, token, ttlMs])).rows; - } catch (err) { - throw op.error( - wrap(`distributedlock: failed to acquire ${key} on postgres`, err), - "acquiring lock on postgres", - ); - } - - if (rows.length === 0) { - op.logger().debug("lock is already held"); - this.#instruments.contention.add(1, { operation: "acquire" }); - return undefined; - } - - return this.#makeLock(key, token, ttlMs); - }); - } - - async ping(): Promise { - try { - await this.#pool.query("SELECT 1"); - } catch (err) { - throw wrap("distributedlock: postgres ping failed", err); - } - } - - /** - * No-op: the pool is injected and owned by the caller (it typically outlives this lock and is - * shared with the rest of the app), so its lifecycle — including draining — is the caller's. - */ - close(): Promise { - return Promise.resolve(); - } - - #makeLock(key: string, token: string, ttlMs: number): Lock { - const release = (): Promise => - this.#observer.run("release", async (op) => { - op.set("key", key); - let result; - try { - result = await this.#pool.query(this.#releaseSql, [key, token]); - } catch (err) { - throw op.error( - wrap(`distributedlock: failed to release ${key} on postgres`, err), - "releasing lock on postgres", - ); - } - if ((result.rowCount ?? 0) === 0) { - op.logger().debug("release ignored: lease no longer owned"); - this.#instruments.contention.add(1, { operation: "release" }); - return false; - } - return true; - }); - - const refresh = (newTtlMs?: number): Promise => - this.#observer.run("refresh", async (op) => { - op.set("key", key); - let extended: unknown[]; - try { - extended = ( - await this.#pool.query(this.#refreshSql, [key, token, newTtlMs ?? ttlMs]) - ).rows; - } catch (err) { - throw op.error( - wrap(`distributedlock: failed to refresh ${key} on postgres`, err), - "refreshing lock on postgres", - ); - } - if (extended.length === 0) { - op.logger().debug("refresh ignored: lease no longer owned"); - this.#instruments.contention.add(1, { operation: "refresh" }); - return false; - } - return true; - }); - - return { key, release, refresh }; - } -} diff --git a/packages/distributedlock/src/providers/redis.node.ts b/packages/distributedlock/src/providers/redis.node.ts deleted file mode 100644 index 631350c..0000000 --- a/packages/distributedlock/src/providers/redis.node.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import { wrap } from "@primandproper/errors"; -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import { Redis } from "ioredis"; - -import type { AcquireOptions, DistributedLock, Lock } from "../distributedlock.js"; - -import { lockInstruments, type LockInstruments } from "./support.js"; - -const o11yName = "distributedlock"; - -/** - * ioredis client wiring shared by the platform's Node providers. Either constructs a client from - * `url` (owned — {@link RedisDistributedLock.close} quits it) or reuses an injected `client` - * (unowned — the caller owns its lifecycle). The constructed client deliberately fails fast instead - * of ioredis's ~30s offline-queue hang against a down Redis: `maxRetriesPerRequest` is capped low so - * a command rejects after a few reconnect attempts, and `commandTimeoutMs` (opt-in) bounds any call. - */ -export interface RedisClientOptions { - url: string; - /** Reuse an existing ioredis client instead of constructing one; the caller owns its lifecycle. */ - client?: Redis; - /** TCP connect timeout in ms (ioredis `connectTimeout`). Defaults to ioredis's 10s. */ - connectTimeoutMs?: number; - /** Reject a command that outlives this many ms. Off by default; the fail-fast timeout knob. */ - commandTimeoutMs?: number; - /** Reconnect attempts a queued command survives before rejecting. Defaults to 3 (ioredis: 20). */ - maxRetriesPerRequest?: number; - /** Whether commands issued while disconnected queue (true) or reject at once. Defaults to true. */ - enableOfflineQueue?: boolean; -} - -export function buildRedisClient(options: RedisClientOptions): { - client: Redis; - owned: boolean; -} { - if (options.client !== undefined) { - return { client: options.client, owned: false }; - } - const client = new Redis(options.url, { - lazyConnect: true, - connectTimeout: options.connectTimeoutMs ?? 10_000, - maxRetriesPerRequest: options.maxRetriesPerRequest ?? 3, - enableOfflineQueue: options.enableOfflineQueue ?? true, - ...(options.commandTimeoutMs !== undefined - ? { commandTimeout: options.commandTimeoutMs } - : {}), - }); - return { client, owned: true }; -} - -export interface RedisDistributedLockOptions extends RedisClientOptions { - keyPrefix?: string; - /** Lease duration when {@link AcquireOptions.ttlMs} is omitted, in milliseconds. */ - defaultTtlMs?: number; -} - -const DEFAULT_TTL_MS = 30_000; - -/** - * Frees the key only if it still holds this caller's token, so a holder that lost its lease - * (expired, then re-acquired by another caller) cannot release the new holder's lock. - * - * KEYS[1] = lock key ARGV[1] = token reply = 1 if freed, 0 otherwise - */ -const RELEASE_SCRIPT = ` -if redis.call("GET", KEYS[1]) == ARGV[1] then - return redis.call("DEL", KEYS[1]) -end -return 0 -`; - -/** - * Resets the lease expiry only if the key still holds this caller's token; a no-op otherwise. - * - * KEYS[1] = lock key ARGV[1] = token ARGV[2] = ttlMs reply = 1 if extended, 0 otherwise - */ -const REFRESH_SCRIPT = ` -if redis.call("GET", KEYS[1]) == ARGV[1] then - return redis.call("PEXPIRE", KEYS[1], ARGV[2]) -end -return 0 -`; - -/** Custom-command names the release/refresh scripts are registered under (via ioredis `defineCommand`). */ -const RELEASE_COMMAND = "dlRelease"; -const REFRESH_COMMAND = "dlRefresh"; - -/** The client shape after the release/refresh commands are registered — EVALSHA under the hood. */ -interface LockScriptClient { - dlRelease(key: string, token: string): Promise; - dlRefresh(key: string, token: string, ttlMs: number): Promise; -} - -/** - * Node-only provider backed by Redis (ioredis). Acquisition is an atomic `SET key token NX PX`, - * so a key is granted iff it is free or has auto-expired against Redis's own clock. Each grant - * stamps a unique token; release and refresh run compare-and-act Lua scripts against that token, - * so a {@link Lock} only ever frees or extends a lease this caller still owns. Single-node Redis - * mutual exclusion (not Redlock — one server, not a quorum); contention is `undefined`, not a throw. - */ -export class RedisDistributedLock implements DistributedLock { - readonly #client: Redis; - readonly #ownsClient: boolean; - readonly #prefix: string; - readonly #defaultTtlMs: number; - readonly #observer: Observer; - readonly #instruments: LockInstruments; - - constructor(options: RedisDistributedLockOptions, deps: ObservabilityDeps = {}) { - ({ client: this.#client, owned: this.#ownsClient } = buildRedisClient(options)); - this.#prefix = options.keyPrefix ?? ""; - this.#defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS; - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#instruments = lockInstruments(o11yName, deps); - // Register the compare-and-act scripts as custom commands so release/refresh go out as - // EVALSHA (ioredis falls back to EVAL + re-caches on NOSCRIPT), not the full script each call. - this.#client.defineCommand(RELEASE_COMMAND, { numberOfKeys: 1, lua: RELEASE_SCRIPT }); - this.#client.defineCommand(REFRESH_COMMAND, { numberOfKeys: 1, lua: REFRESH_SCRIPT }); - } - - acquire(key: string, opts: AcquireOptions = {}): Promise { - return this.#observer.run("acquire", async (op) => { - op.set("key", key); - const ttlMs = opts.ttlMs ?? this.#defaultTtlMs; - const token = randomUUID(); - const fullKey = this.#key(key); - - let reply: "OK" | null; - try { - reply = await this.#client.set(fullKey, token, "PX", ttlMs, "NX"); - } catch (err) { - throw op.error( - wrap(`distributedlock: failed to acquire ${key} on redis`, err), - "acquiring lock on redis", - ); - } - - if (reply === null) { - op.logger().debug("lock is already held"); - this.#instruments.contention.add(1, { operation: "acquire" }); - return undefined; - } - - return this.#makeLock(key, fullKey, token, ttlMs); - }); - } - - async ping(): Promise { - try { - await this.#client.ping(); - } catch (err) { - throw wrap("distributedlock: redis ping failed", err); - } - } - - /** - * Closes the connection, draining in-flight commands (`quit`) and falling back to an immediate - * `disconnect` if the graceful quit fails. A no-op for an injected client — the caller owns it. - */ - async close(): Promise { - if (!this.#ownsClient) { - return; - } - try { - await this.#client.quit(); - } catch { - this.#client.disconnect(); - } - } - - #key(key: string): string { - return this.#prefix + key; - } - - #makeLock(key: string, fullKey: string, token: string, ttlMs: number): Lock { - const release = (): Promise => - this.#observer.run("release", async (op) => { - op.set("key", key); - let freed: number; - try { - freed = await (this.#client as unknown as LockScriptClient).dlRelease( - fullKey, - token, - ); - } catch (err) { - throw op.error( - wrap(`distributedlock: failed to release ${key} on redis`, err), - "releasing lock on redis", - ); - } - if (freed === 0) { - op.logger().debug("release ignored: lease no longer owned"); - this.#instruments.contention.add(1, { operation: "release" }); - return false; - } - return true; - }); - - const refresh = (newTtlMs?: number): Promise => - this.#observer.run("refresh", async (op) => { - op.set("key", key); - let extended: number; - try { - extended = await (this.#client as unknown as LockScriptClient).dlRefresh( - fullKey, - token, - newTtlMs ?? ttlMs, - ); - } catch (err) { - throw op.error( - wrap(`distributedlock: failed to refresh ${key} on redis`, err), - "refreshing lock on redis", - ); - } - if (extended === 0) { - op.logger().debug("refresh ignored: lease no longer owned"); - this.#instruments.contention.add(1, { operation: "refresh" }); - return false; - } - return true; - }); - - return { key, release, refresh }; - } -} diff --git a/packages/distributedlock/src/providers/support.ts b/packages/distributedlock/src/providers/support.ts deleted file mode 100644 index 98a4ea2..0000000 --- a/packages/distributedlock/src/providers/support.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - makeMetrics, - type Metrics, - type ObservabilityDeps, -} from "@primandproper/observability"; - -type Counter = ReturnType; - -/** - * The contention counter every distributed-lock provider mints in its constructor. It ticks once - * per contention event: an `acquire` that found the key already held, or a `release`/`refresh` - * that found its lease lost (expired or taken over by another holder). The increment is tagged - * with `operation` (`acquire` | `release` | `refresh`) so the three cases stay distinguishable. - * The meter is registered under the provider's o11y name so the instrument groups with its spans - * and logs. - */ -export interface LockInstruments { - contention: Counter; -} - -/** Builds the `distributedlock.contention` counter, defaulting to the noop meter. */ -export function lockInstruments( - o11yName: string, - deps: ObservabilityDeps | undefined, -): LockInstruments { - const metrics = makeMetrics(o11yName, deps?.metrics); - return { - contention: metrics.counter("distributedlock.contention", { - description: - "Count of lock-contention events, tagged by operation (acquire/release/refresh).", - }), - }; -} diff --git a/packages/distributedlock/tsconfig.json b/packages/distributedlock/tsconfig.json deleted file mode 100644 index bdb9327..0000000 --- a/packages/distributedlock/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022"], - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/distributedlock/tsup.config.ts b/packages/distributedlock/tsup.config.ts deleted file mode 100644 index 8bf2394..0000000 --- a/packages/distributedlock/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { index: "src/index.ts" }, - platform: "node", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: true, -}); diff --git a/packages/email/CHANGELOG.md b/packages/email/CHANGELOG.md deleted file mode 100644 index a1272ba..0000000 --- a/packages/email/CHANGELOG.md +++ /dev/null @@ -1,13 +0,0 @@ -# @primandproper/email - -## 0.0.2 - -### Patch Changes - -- a124406: Add per-provider `timeoutMs` and optional `retry` config, a shared HTTP sender, and sender instrumentation. -- Updated dependencies [a124406] -- Updated dependencies [a124406] -- Updated dependencies [a124406] - - @primandproper/errors@0.0.2 - - @primandproper/observability@0.1.0 - - @primandproper/retry@0.1.0 diff --git a/packages/email/package.json b/packages/email/package.json deleted file mode 100644 index f42139d..0000000 --- a/packages/email/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@primandproper/email", - "version": "0.0.2", - "type": "module", - "description": "Server-only email sending: an Email interface with swappable providers.", - "license": "AGPL-3.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/primandproper/platform-ts.git", - "directory": "packages/email" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "eslint .", - "clean": "rm -rf dist .turbo" - }, - "dependencies": { - "@primandproper/errors": "workspace:*", - "@primandproper/observability": "workspace:*", - "@primandproper/retry": "workspace:*", - "postmark": "4.0.7", - "zod": "3.25.76" - } -} diff --git a/packages/email/src/config.ts b/packages/email/src/config.ts deleted file mode 100644 index a0d4170..0000000 --- a/packages/email/src/config.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { RetryConfigSchema } from "@primandproper/retry"; -import { z } from "zod"; - -/** Thirty seconds — the default per-send deadline shared by every REST transport. */ -const DEFAULT_TIMEOUT_MS = 30_000; - -/** - * Per-send deadline in milliseconds; `0` disables it. Shared by every REST transport so a wedged - * vendor call fails fast instead of hanging (NET-1). - */ -const timeoutMs = z.number().int().nonnegative().default(DEFAULT_TIMEOUT_MS); - -/** - * Optional retry policy for transient send failures (network/timeout errors and 429/5xx). Omitted - * means a single attempt — retry is opt-in because a send is not idempotent, so a replay after an - * ambiguous failure can double-deliver. - */ -const retry = RetryConfigSchema.optional(); - -/** Resend-provider config: an API key authenticates against the Resend REST API. */ -export const ResendEmailConfigSchema = z.object({ - apiKey: z.string(), - /** Overrides the API base URL. Defaults to Resend's public endpoint. */ - baseUrl: z.string().default("https://api.resend.com"), - timeoutMs, - retry, -}); - -export type ResendEmailConfig = z.infer; - -/** Postmark-provider config: a server API token authenticates the Postmark `ServerClient`. */ -export const PostmarkEmailConfigSchema = z.object({ - serverToken: z.string(), -}); - -export type PostmarkEmailConfig = z.infer; - -/** SendGrid-provider config: an API key authenticates against the SendGrid v3 Mail Send API. */ -export const SendgridEmailConfigSchema = z.object({ - apiKey: z.string(), - baseUrl: z.string().default("https://api.sendgrid.com"), - timeoutMs, - retry, -}); - -export type SendgridEmailConfig = z.infer; - -/** Mailgun-provider config: a private API key plus the sending domain. */ -export const MailgunEmailConfigSchema = z.object({ - apiKey: z.string(), - domain: z.string(), - /** Defaults to the US endpoint; use `https://api.eu.mailgun.net` for the EU region. */ - baseUrl: z.string().default("https://api.mailgun.net"), - timeoutMs, - retry, -}); - -export type MailgunEmailConfig = z.infer; - -/** Mailjet-provider config: an API key + secret key pair for HTTP basic auth. */ -export const MailjetEmailConfigSchema = z.object({ - apiKey: z.string(), - secretKey: z.string(), - baseUrl: z.string().default("https://api.mailjet.com"), - timeoutMs, - retry, -}); - -export type MailjetEmailConfig = z.infer; - -/** - * Email config. Replaces the Go `env:`-tagged struct + ozzo `ValidateWithContext`. `noop` - * (default) drops every send; `memory` captures sends for tests; `resend`, `sendgrid`, `mailgun`, - * and `mailjet` deliver over their respective REST APIs; `postmark` delivers via the Postmark - * `ServerClient` SDK. All real transports stay server-side. - */ -export const EmailConfigSchema = z - .object({ - provider: z - .enum(["noop", "memory", "resend", "postmark", "sendgrid", "mailgun", "mailjet"]) - .default("noop"), - resend: ResendEmailConfigSchema.optional(), - postmark: PostmarkEmailConfigSchema.optional(), - sendgrid: SendgridEmailConfigSchema.optional(), - mailgun: MailgunEmailConfigSchema.optional(), - mailjet: MailjetEmailConfigSchema.optional(), - }) - .superRefine((cfg, ctx) => { - const requireSection = ( - provider: typeof cfg.provider, - section: keyof typeof cfg, - ): void => { - if (cfg.provider === provider && cfg[section] === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [section], - message: `${provider} config is required when provider is '${provider}'`, - }); - } - }; - requireSection("resend", "resend"); - requireSection("postmark", "postmark"); - requireSection("sendgrid", "sendgrid"); - requireSection("mailgun", "mailgun"); - requireSection("mailjet", "mailjet"); - }); - -export type EmailConfig = z.infer; -export type EmailConfigInput = z.input; diff --git a/packages/email/src/email.test.ts b/packages/email/src/email.test.ts deleted file mode 100644 index 16259cf..0000000 --- a/packages/email/src/email.test.ts +++ /dev/null @@ -1,639 +0,0 @@ -import { - makeRecordingObserver, - type MeterProvider, - type ObservabilityDeps, -} from "@primandproper/observability"; -import { describe, expect, it, vi } from "vitest"; - -import { EmptyEmailBodyError, type Email, type EmailMessage } from "./email.js"; -import type { FetchLike } from "./providers/http.js"; -import { MailgunEmail } from "./providers/mailgun.js"; -import { MailjetEmail } from "./providers/mailjet.js"; -import { MemoryEmail } from "./providers/memory.js"; -import { NoopEmail } from "./providers/noop.js"; -import { - PostmarkEmail, - type PostmarkClientLike, - type PostmarkMessage, - type PostmarkSendResponse, -} from "./providers/postmark.node.js"; -import { ResendEmail } from "./providers/resend.js"; -import { SendgridEmail } from "./providers/sendgrid.js"; - -import { provideEmail } from "./index.js"; - -const sample: EmailMessage = { - to: "to@example.com", - from: "from@example.com", - subject: "hello", - text: "hi there", -}; - -/** - * Provider-agnostic conformance suite. Running the same assertions against multiple - * providers proves the `Email` interface is implementation-independent. - */ -function conformance(name: string, make: () => Email): void { - describe(name, () => { - it("sends without throwing", async () => { - await expect(make().send(sample)).resolves.toBeDefined(); - }); - - it("pings without throwing", async () => { - await expect(make().ping()).resolves.toBeUndefined(); - }); - - it("rejects a message with no text or html body", async () => { - await expect( - make().send({ to: "a@b.com", from: "c@d.com", subject: "x" }), - ).rejects.toBeInstanceOf(EmptyEmailBodyError); - }); - }); -} - -/** A Postmark client that records sends and returns a canned response, no network. */ -function fakePostmarkClient(response: PostmarkSendResponse = { MessageID: "pm_stub" }): { - client: PostmarkClientLike; - sent: PostmarkMessage[]; -} { - const sent: PostmarkMessage[] = []; - const client: PostmarkClientLike = { - sendEmail(message) { - sent.push(message); - return Promise.resolve(response); - }, - getServer() { - return Promise.resolve({}); - }, - }; - return { client, sent }; -} - -conformance("NoopEmail", () => new NoopEmail()); -conformance("MemoryEmail", () => new MemoryEmail()); -conformance( - "PostmarkEmail", - () => new PostmarkEmail({ serverToken: "tok", client: fakePostmarkClient().client }), -); - -describe("MemoryEmail", () => { - it("captures every sent message", async () => { - const email = new MemoryEmail(); - - await email.send(sample); - await email.send({ ...sample, subject: "second" }); - - expect(email.sent).toHaveLength(2); - expect(email.sent[0]?.subject).toBe("hello"); - expect(email.sent[1]?.subject).toBe("second"); - expect(email.sent[0]?.to).toBe("to@example.com"); - }); -}); - -/** A MeterProvider that records every counter `add`, so tests can assert send/error counters. */ -function countingMeter(): { deps: ObservabilityDeps; counts: Map } { - const counts = new Map(); - const meter = { - createCounter: (name: string) => ({ - add: (value: number) => counts.set(name, (counts.get(name) ?? 0) + value), - }), - // makeObserver auto-mints an operation-duration histogram; the fake meter must provide - // it even though these tests only assert on the counters. - createHistogram: () => ({ record: () => undefined }), - createUpDownCounter: () => ({ add: () => undefined }), - createGauge: () => ({ record: () => undefined }), - }; - const provider = { getMeter: () => meter } as unknown as MeterProvider; - return { deps: { metrics: provider }, counts }; -} - -/** Builds a fake `fetch` that records its call and returns the given response. */ -function fakeFetch(response: Response): { - fetch: FetchLike; - calls: { url: string; init: RequestInit }[]; -} { - const calls: { url: string; init: RequestInit }[] = []; - const fetch: FetchLike = (url, init) => { - calls.push({ url, init }); - return Promise.resolve(response); - }; - return { fetch, calls }; -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - -describe("ResendEmail", () => { - it("POSTs to the emails endpoint with the bearer token", async () => { - const { fetch, calls } = fakeFetch(jsonResponse({ id: "re_123" })); - const email = new ResendEmail({ apiKey: "key_abc", fetch }); - - const result = await email.send(sample); - - expect(calls[0]?.url).toBe("https://api.resend.com/emails"); - expect(calls[0]?.init.method).toBe("POST"); - const headers = calls[0]?.init.headers as Record; - expect(headers.authorization).toBe("Bearer key_abc"); - expect(result.id).toBe("re_123"); - }); - - it("serializes the message into the request body", async () => { - const { fetch, calls } = fakeFetch(jsonResponse({ id: "re_1" })); - const email = new ResendEmail({ apiKey: "k", fetch }); - - await email.send({ ...sample, html: "

hi

", replyTo: "reply@example.com" }); - - const body = JSON.parse(calls[0]?.init.body as string) as { - to: string; - from: string; - subject: string; - text: string; - html: string; - reply_to: string; - }; - expect(body.to).toBe("to@example.com"); - expect(body.from).toBe("from@example.com"); - expect(body.subject).toBe("hello"); - expect(body.text).toBe("hi there"); - expect(body.html).toBe("

hi

"); - expect(body.reply_to).toBe("reply@example.com"); - }); - - it("honors a custom base URL", async () => { - const { fetch, calls } = fakeFetch(jsonResponse({ id: "re_1" })); - const email = new ResendEmail({ - apiKey: "k", - baseUrl: "https://resend.internal", - fetch, - }); - - await email.send(sample); - - expect(calls[0]?.url).toBe("https://resend.internal/emails"); - }); - - it("returns an empty result when the response omits an id", async () => { - const { fetch } = fakeFetch(jsonResponse({})); - const email = new ResendEmail({ apiKey: "k", fetch }); - - expect(await email.send(sample)).toStrictEqual({}); - }); - - it("does not throw on a 2xx with an empty or non-JSON body (EM-1)", async () => { - const empty = fakeFetch(new Response("", { status: 200 })); - await expect( - new ResendEmail({ apiKey: "k", fetch: empty.fetch }).send(sample), - ).resolves.toStrictEqual({}); - - const nonJson = fakeFetch(new Response("OK", { status: 200 })); - await expect( - new ResendEmail({ apiKey: "k", fetch: nonJson.fetch }).send(sample), - ).resolves.toStrictEqual({}); - }); - - it("pings a real authenticated endpoint (GET /domains), not OPTIONS /emails (EM-1)", async () => { - const { fetch, calls } = fakeFetch(jsonResponse({ data: [] })); - await new ResendEmail({ apiKey: "k", fetch }).ping(); - - expect(calls[0]?.url).toBe("https://api.resend.com/domains"); - expect(calls[0]?.init.method).toBe("GET"); - }); - - it("throws with status and body on a non-ok response", async () => { - const { fetch } = fakeFetch(new Response("bad request", { status: 422 })); - const email = new ResendEmail({ apiKey: "k", fetch }); - - await expect(email.send(sample)).rejects.toThrow(/422.*bad request/); - }); - - it("falls back to globalThis.fetch when none is injected", async () => { - const spy = vi - .spyOn(globalThis, "fetch") - .mockResolvedValue(jsonResponse({ id: "re_global" })); - const email = new ResendEmail({ apiKey: "k" }); - - const result = await email.send(sample); - - expect(result.id).toBe("re_global"); - expect(spy).toHaveBeenCalledOnce(); - spy.mockRestore(); - }); -}); - -describe("PostmarkEmail", () => { - it("maps the message into Postmark's PascalCase fields", async () => { - const { client, sent } = fakePostmarkClient({ MessageID: "pm_1" }); - const email = new PostmarkEmail({ serverToken: "tok", client }); - - const result = await email.send({ - ...sample, - to: ["a@example.com", "b@example.com"], - html: "

hi

", - cc: "cc@example.com", - replyTo: "reply@example.com", - }); - - expect(sent[0]?.From).toBe("from@example.com"); - expect(sent[0]?.To).toBe("a@example.com, b@example.com"); - expect(sent[0]?.Subject).toBe("hello"); - expect(sent[0]?.TextBody).toBe("hi there"); - expect(sent[0]?.HtmlBody).toBe("

hi

"); - expect(sent[0]?.Cc).toBe("cc@example.com"); - expect(sent[0]?.ReplyTo).toBe("reply@example.com"); - expect(result.id).toBe("pm_1"); - }); - - it("returns an empty result when the response omits a MessageID", async () => { - const { client } = fakePostmarkClient({}); - const email = new PostmarkEmail({ serverToken: "tok", client }); - - expect(await email.send(sample)).toStrictEqual({}); - }); - - it("throws with the error code and message on a non-zero ErrorCode", async () => { - const { client } = fakePostmarkClient({ ErrorCode: 300, Message: "Invalid email" }); - const email = new PostmarkEmail({ serverToken: "tok", client }); - - await expect(email.send(sample)).rejects.toThrow(/300.*Invalid email/); - }); - - it("wraps an error thrown by the SDK", async () => { - const client: PostmarkClientLike = { - sendEmail() { - return Promise.reject(new Error("network down")); - }, - getServer() { - return Promise.resolve({}); - }, - }; - const email = new PostmarkEmail({ serverToken: "tok", client }); - - await expect(email.send(sample)).rejects.toThrow( - /postmark send failed: network down/, - ); - }); -}); - -describe("SendgridEmail", () => { - it("POSTs to the mail send endpoint and reads the message-id header", async () => { - const { fetch, calls } = fakeFetch( - new Response(null, { status: 202, headers: { "x-message-id": "sg_1" } }), - ); - const email = new SendgridEmail({ apiKey: "SG.key", fetch }); - - const result = await email.send({ - ...sample, - html: "

hi

", - cc: "cc@example.com", - replyTo: "r@example.com", - }); - - expect(calls[0]?.url).toBe("https://api.sendgrid.com/v3/mail/send"); - const headers = calls[0]?.init.headers as Record; - expect(headers.authorization).toBe("Bearer SG.key"); - const body = JSON.parse(calls[0]?.init.body as string) as { - personalizations: { to: { email: string }[]; cc: { email: string }[] }[]; - from: { email: string }; - content: { type: string; value: string }[]; - reply_to: { email: string }; - }; - expect(body.personalizations[0]?.to).toStrictEqual([{ email: "to@example.com" }]); - expect(body.personalizations[0]?.cc).toStrictEqual([{ email: "cc@example.com" }]); - expect(body.from).toStrictEqual({ email: "from@example.com" }); - expect(body.content).toStrictEqual([ - { type: "text/plain", value: "hi there" }, - { type: "text/html", value: "

hi

" }, - ]); - expect(body.reply_to).toStrictEqual({ email: "r@example.com" }); - expect(result.id).toBe("sg_1"); - }); - - it("rejects a message with no body", async () => { - const { fetch } = fakeFetch(new Response(null, { status: 202 })); - await expect( - new SendgridEmail({ apiKey: "k", fetch }).send({ - to: "a@b.com", - from: "c@d.com", - subject: "x", - }), - ).rejects.toBeInstanceOf(EmptyEmailBodyError); - }); - - it("throws with status and body on a non-ok response", async () => { - const { fetch } = fakeFetch(new Response("bad", { status: 400 })); - await expect(new SendgridEmail({ apiKey: "k", fetch }).send(sample)).rejects.toThrow( - /400.*bad/, - ); - }); -}); - -describe("MailgunEmail", () => { - it("POSTs a form body with basic auth and reads the id", async () => { - const { fetch, calls } = fakeFetch( - jsonResponse({ id: "", message: "Queued" }), - ); - const email = new MailgunEmail({ - apiKey: "key-abc", - domain: "mg.example.com", - fetch, - }); - - const result = await email.send({ - ...sample, - cc: ["c1@x.com", "c2@x.com"], - replyTo: "r@example.com", - }); - - expect(calls[0]?.url).toBe("https://api.mailgun.net/v3/mg.example.com/messages"); - const headers = calls[0]?.init.headers as Record; - expect(headers.authorization).toBe(`Basic ${btoa("api:key-abc")}`); - const form = new URLSearchParams(calls[0]?.init.body as string); - expect(form.get("from")).toBe("from@example.com"); - expect(form.get("to")).toBe("to@example.com"); - expect(form.get("subject")).toBe("hello"); - expect(form.get("text")).toBe("hi there"); - expect(form.getAll("cc")).toStrictEqual(["c1@x.com", "c2@x.com"]); - expect(form.get("h:Reply-To")).toBe("r@example.com"); - expect(result.id).toBe(""); - }); - - it("honors the EU base URL", async () => { - const { fetch, calls } = fakeFetch(jsonResponse({ id: "x" })); - await new MailgunEmail({ - apiKey: "k", - domain: "d", - baseUrl: "https://api.eu.mailgun.net", - fetch, - }).send(sample); - expect(calls[0]?.url).toBe("https://api.eu.mailgun.net/v3/d/messages"); - }); -}); - -describe("MailjetEmail", () => { - it("POSTs the Messages payload with basic auth and reads the message uuid", async () => { - const { fetch, calls } = fakeFetch( - jsonResponse({ Messages: [{ To: [{ MessageID: 123, MessageUUID: "uuid-1" }] }] }), - ); - const email = new MailjetEmail({ apiKey: "ak", secretKey: "sk", fetch }); - - const result = await email.send({ ...sample, html: "

hi

" }); - - expect(calls[0]?.url).toBe("https://api.mailjet.com/v3.1/send"); - const headers = calls[0]?.init.headers as Record; - expect(headers.authorization).toBe(`Basic ${btoa("ak:sk")}`); - const body = JSON.parse(calls[0]?.init.body as string) as { - Messages: { - From: { Email: string }; - To: { Email: string }[]; - TextPart: string; - HTMLPart: string; - }[]; - }; - expect(body.Messages[0]?.From).toStrictEqual({ Email: "from@example.com" }); - expect(body.Messages[0]?.To).toStrictEqual([{ Email: "to@example.com" }]); - expect(body.Messages[0]?.TextPart).toBe("hi there"); - expect(body.Messages[0]?.HTMLPart).toBe("

hi

"); - expect(result.id).toBe("uuid-1"); - }); - - it("falls back to the numeric MessageID when no uuid is present", async () => { - const { fetch } = fakeFetch( - jsonResponse({ Messages: [{ To: [{ MessageID: 99 }] }] }), - ); - const result = await new MailjetEmail({ apiKey: "a", secretKey: "s", fetch }).send( - sample, - ); - expect(result.id).toBe("99"); - }); - - it("does not throw on a 2xx with an empty or non-JSON body (EM-1)", async () => { - const { fetch } = fakeFetch(new Response("", { status: 200 })); - await expect( - new MailjetEmail({ apiKey: "a", secretKey: "s", fetch }).send(sample), - ).resolves.toStrictEqual({}); - }); -}); - -describe("email instrumentation", () => { - it("counts the send and records the recipient domain and request id on success", async () => { - const { deps, counts } = countingMeter(); - const observer = makeRecordingObserver(); - const { fetch } = fakeFetch(jsonResponse({ id: "re_ok" })); - const email = new ResendEmail({ apiKey: "k", fetch }, { ...deps, observer }); - - await email.send(sample); - - expect(counts.get("email_sends")).toBe(1); - expect(observer.observations).toContainEqual( - expect.objectContaining({ key: "recipientDomain", value: "example.com" }), - ); - expect(observer.observations).toContainEqual( - expect.objectContaining({ key: "requestId", value: "re_ok" }), - ); - }); - - it("counts the error and logs the recipient domain and request id on failure", async () => { - const { deps, counts } = countingMeter(); - const observer = makeRecordingObserver(); - const { fetch } = fakeFetch( - new Response("boom", { status: 500, headers: { "x-request-id": "req_err" } }), - ); - const email = new ResendEmail({ apiKey: "k", fetch }, { ...deps, observer }); - - await expect(email.send(sample)).rejects.toThrow(/500.*boom/); - - expect(counts.get("email_errors")).toBe(1); - expect(observer.observations).toContainEqual( - expect.objectContaining({ key: "recipientDomain", value: "example.com" }), - ); - expect(observer.observations).toContainEqual( - expect.objectContaining({ key: "requestId", value: "req_err" }), - ); - expect(observer.errors.map((e) => e.description)).toContain( - "resend send failed with status 500", - ); - }); - - it("passes the cause to the logger when the postmark SDK throws", async () => { - const { deps, counts } = countingMeter(); - const observer = makeRecordingObserver(); - const client: PostmarkClientLike = { - sendEmail() { - return Promise.reject(new Error("network down")); - }, - getServer() { - return Promise.resolve({}); - }, - }; - const email = new PostmarkEmail( - { serverToken: "tok", client }, - { ...deps, observer }, - ); - - await expect(email.send(sample)).rejects.toThrow(/network down/); - - expect(counts.get("email_errors")).toBe(1); - const recorded = observer.errors[0]; - expect(recorded?.description).toBe("postmark send failed"); - expect((recorded?.err as Error).message).toMatch(/network down/); - expect(observer.observations).toContainEqual( - expect.objectContaining({ key: "recipientDomain", value: "example.com" }), - ); - }); -}); - -describe("email resilience (timeout + retry)", () => { - it("passes an abort signal into the send fetch by default (timeout on)", async () => { - const { fetch, calls } = fakeFetch(jsonResponse({ id: "re_ok" })); - const email = new ResendEmail({ apiKey: "k", fetch }); - - await email.send(sample); - - const signal = calls[0]?.init.signal; - expect(signal).toBeInstanceOf(AbortSignal); - }); - - it("sends no signal when the timeout is disabled", async () => { - const { fetch, calls } = fakeFetch(jsonResponse({ id: "re_ok" })); - const email = new ResendEmail({ apiKey: "k", timeoutMs: 0, fetch }); - - await email.send(sample); - - expect(calls[0]?.init.signal ?? undefined).toBeUndefined(); - }); - - it("does not retry by default", async () => { - let attempts = 0; - const fetch: FetchLike = () => { - attempts += 1; - return Promise.resolve(new Response("boom", { status: 503 })); - }; - const email = new ResendEmail({ apiKey: "k", fetch }); - - await expect(email.send(sample)).rejects.toThrow(/503/); - expect(attempts).toBe(1); - }); - - it("retries a 503 when a retry policy is configured, then surfaces the final response", async () => { - let attempts = 0; - const fetch: FetchLike = () => { - attempts += 1; - return Promise.resolve(new Response("still down", { status: 503 })); - }; - const email = new ResendEmail({ - apiKey: "k", - retry: { - maxAttempts: 3, - baseDelayMs: 0, - maxDelayMs: 0, - jitter: 0, - maxElapsedMs: 0, - }, - fetch, - }); - - await expect(email.send(sample)).rejects.toThrow(/503.*still down/); - // Three attempts, then the final 503 surfaces through the provider's own error path. - expect(attempts).toBe(3); - }); - - it("does not retry a 4xx even when a retry policy is configured", async () => { - let attempts = 0; - const fetch: FetchLike = () => { - attempts += 1; - return Promise.resolve(new Response("bad request", { status: 400 })); - }; - const email = new ResendEmail({ - apiKey: "k", - retry: { - maxAttempts: 3, - baseDelayMs: 0, - maxDelayMs: 0, - jitter: 0, - maxElapsedMs: 0, - }, - fetch, - }); - - await expect(email.send(sample)).rejects.toThrow(/400/); - expect(attempts).toBe(1); - }); - - it("retries a transient network error, then succeeds", async () => { - let attempts = 0; - const fetch: FetchLike = () => { - attempts += 1; - return attempts < 2 - ? Promise.reject(new Error("ECONNRESET")) - : Promise.resolve(jsonResponse({ id: "re_ok" })); - }; - const email = new ResendEmail({ - apiKey: "k", - retry: { - maxAttempts: 3, - baseDelayMs: 0, - maxDelayMs: 0, - jitter: 0, - maxElapsedMs: 0, - }, - fetch, - }); - - const result = await email.send(sample); - expect(result.id).toBe("re_ok"); - expect(attempts).toBe(2); - }); -}); - -describe("provideEmail", () => { - it("defaults to the noop provider", () => { - expect(provideEmail()).toBeInstanceOf(NoopEmail); - }); - - it("builds a memory provider", () => { - expect(provideEmail({ provider: "memory" })).toBeInstanceOf(MemoryEmail); - }); - - it("builds a resend provider", () => { - const email = provideEmail({ provider: "resend", resend: { apiKey: "k" } }); - expect(email).toBeInstanceOf(ResendEmail); - }); - - it("rejects a resend provider without config", () => { - expect(() => provideEmail({ provider: "resend" })).toThrow(); - }); - - it("builds a postmark provider", () => { - const email = provideEmail({ - provider: "postmark", - postmark: { serverToken: "tok" }, - }); - expect(email).toBeInstanceOf(PostmarkEmail); - }); - - it("rejects a postmark provider without config", () => { - expect(() => provideEmail({ provider: "postmark" })).toThrow(); - }); - - it("builds the REST providers from config", () => { - expect( - provideEmail({ provider: "sendgrid", sendgrid: { apiKey: "k" } }), - ).toBeInstanceOf(SendgridEmail); - expect( - provideEmail({ provider: "mailgun", mailgun: { apiKey: "k", domain: "d" } }), - ).toBeInstanceOf(MailgunEmail); - expect( - provideEmail({ provider: "mailjet", mailjet: { apiKey: "k", secretKey: "s" } }), - ).toBeInstanceOf(MailjetEmail); - }); - - it("rejects the REST providers without config", () => { - expect(() => provideEmail({ provider: "sendgrid" })).toThrow(); - expect(() => provideEmail({ provider: "mailgun" })).toThrow(); - expect(() => provideEmail({ provider: "mailjet" })).toThrow(); - }); -}); diff --git a/packages/email/src/email.ts b/packages/email/src/email.ts deleted file mode 100644 index 5bb3e29..0000000 --- a/packages/email/src/email.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { PlatformError } from "@primandproper/errors"; - -/** A list of recipients, as a single address or several. */ -export type Recipients = string | string[]; - -/** - * An email to send. At least one of {@link EmailMessage.text} or {@link EmailMessage.html} - * must be present — enforce it with {@link assertHasBody} before handing the message to a - * transport. - */ -export interface EmailMessage { - /** One or more recipient addresses. */ - to: Recipients; - /** The sender address. */ - from: string; - subject: string; - /** The plain-text body. At least one of `text`/`html` is required. */ - text?: string; - /** The HTML body. At least one of `text`/`html` is required. */ - html?: string; - cc?: Recipients; - bcc?: Recipients; - replyTo?: string; -} - -/** The outcome of a send. `id` is the provider's message identifier, when it returns one. */ -export interface SendResult { - id?: string; -} - -/** - * The email contract. {@link send} delivers a message; {@link ping} verifies the backing - * transport is reachable. A provider may return an empty {@link SendResult} when it has no - * identifier to report (e.g. the noop provider). - */ -export interface Email { - send(message: EmailMessage): Promise; - ping(): Promise; -} - -/** Thrown when a message carries neither a `text` nor an `html` body. */ -export class EmptyEmailBodyError extends PlatformError { - constructor() { - super("email/empty-body", "email message must have a text or html body"); - this.name = "EmptyEmailBodyError"; - } -} - -/** - * Throws {@link EmptyEmailBodyError} unless the message has a non-empty `text` or `html` - * body. Providers call this before dispatch so the requirement holds regardless of transport. - */ -export function assertHasBody(message: EmailMessage): void { - if ( - (message.text === undefined || message.text === "") && - (message.html === undefined || message.html === "") - ) { - throw new EmptyEmailBodyError(); - } -} diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts deleted file mode 100644 index 21aa3c5..0000000 --- a/packages/email/src/index.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { ObservabilityDeps } from "@primandproper/observability"; - -import { EmailConfigSchema, type EmailConfigInput } from "./config.js"; -import type { Email } from "./email.js"; -import { MailgunEmail } from "./providers/mailgun.js"; -import { MailjetEmail } from "./providers/mailjet.js"; -import { MemoryEmail } from "./providers/memory.js"; -import { NoopEmail } from "./providers/noop.js"; -import { PostmarkEmail } from "./providers/postmark.node.js"; -import { ResendEmail } from "./providers/resend.js"; -import { SendgridEmail } from "./providers/sendgrid.js"; - -export * from "./email.js"; -export * from "./config.js"; -export { NoopEmail } from "./providers/noop.js"; -export { MemoryEmail } from "./providers/memory.js"; -export type { FetchLike } from "./providers/http.js"; -export { ResendEmail, type ResendEmailOptions } from "./providers/resend.js"; -export { - PostmarkEmail, - type PostmarkEmailOptions, - type PostmarkClientLike, - type PostmarkMessage, - type PostmarkSendResponse, -} from "./providers/postmark.node.js"; -export { SendgridEmail, type SendgridEmailOptions } from "./providers/sendgrid.js"; -export { MailgunEmail, type MailgunEmailOptions } from "./providers/mailgun.js"; -export { MailjetEmail, type MailjetEmailOptions } from "./providers/mailjet.js"; - -/** - * Validates config and returns the matching {@link Email}. Mirrors the Go platform's - * `ProvideEmailer`. Supports `noop` (default), `memory`, `resend`, `postmark`, `sendgrid`, - * `mailgun`, and `mailjet`. - */ -export function provideEmail(config?: EmailConfigInput, deps?: ObservabilityDeps): Email { - const cfg = EmailConfigSchema.parse(config ?? {}); - switch (cfg.provider) { - case "noop": - return new NoopEmail(deps); - case "memory": - return new MemoryEmail(); - case "resend": - // superRefine guarantees this, but narrow for the type checker. - if (cfg.resend === undefined) { - throw new Error("resend config is required when provider is 'resend'"); - } - return new ResendEmail( - { - apiKey: cfg.resend.apiKey, - baseUrl: cfg.resend.baseUrl, - timeoutMs: cfg.resend.timeoutMs, - retry: cfg.resend.retry, - }, - deps, - ); - case "postmark": - // superRefine guarantees this, but narrow for the type checker. - if (cfg.postmark === undefined) { - throw new Error("postmark config is required when provider is 'postmark'"); - } - return new PostmarkEmail({ serverToken: cfg.postmark.serverToken }, deps); - case "sendgrid": - if (cfg.sendgrid === undefined) { - throw new Error("sendgrid config is required when provider is 'sendgrid'"); - } - return new SendgridEmail( - { - apiKey: cfg.sendgrid.apiKey, - baseUrl: cfg.sendgrid.baseUrl, - timeoutMs: cfg.sendgrid.timeoutMs, - retry: cfg.sendgrid.retry, - }, - deps, - ); - case "mailgun": - if (cfg.mailgun === undefined) { - throw new Error("mailgun config is required when provider is 'mailgun'"); - } - return new MailgunEmail( - { - apiKey: cfg.mailgun.apiKey, - domain: cfg.mailgun.domain, - baseUrl: cfg.mailgun.baseUrl, - timeoutMs: cfg.mailgun.timeoutMs, - retry: cfg.mailgun.retry, - }, - deps, - ); - case "mailjet": - if (cfg.mailjet === undefined) { - throw new Error("mailjet config is required when provider is 'mailjet'"); - } - return new MailjetEmail( - { - apiKey: cfg.mailjet.apiKey, - secretKey: cfg.mailjet.secretKey, - baseUrl: cfg.mailjet.baseUrl, - timeoutMs: cfg.mailjet.timeoutMs, - retry: cfg.mailjet.retry, - }, - deps, - ); - } -} diff --git a/packages/email/src/providers/http.ts b/packages/email/src/providers/http.ts deleted file mode 100644 index 61420eb..0000000 --- a/packages/email/src/providers/http.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { Logger } from "@primandproper/observability"; -import { exponentialBackoff, type Policy, type RetryConfig } from "@primandproper/retry"; - -import type { Recipients } from "../email.js"; - -/** The slice of `fetch` the REST providers rely on. Injectable so tests need no network. */ -export type FetchLike = (input: string, init: RequestInit) => Promise; - -/** Thirty seconds, in milliseconds — the default per-request deadline for a vendor call. */ -export const DEFAULT_EMAIL_TIMEOUT_MS = 30_000; - -/** - * Whether a vendor response status is worth retrying. Vendors document request-timeout (408), - * rate-limit (429), and 5xx as transient; a 4xx is a client error that a replay won't fix. - */ -export function isRetryableStatus(status: number): boolean { - return status === 408 || status === 429 || status >= 500; -} - -/** - * Internal marker thrown so the retry policy re-attempts a retryable-status response. It carries - * the offending {@link Response} so that once retries are exhausted the provider still reads its - * body/headers for the error log instead of losing them. - */ -class RetryableResponseError extends Error { - readonly response: Response; - constructor(response: Response) { - super(`retryable HTTP status ${String(response.status)}`); - this.name = "RetryableResponseError"; - this.response = response; - } -} - -/** Builds a per-request timeout signal combined with the caller's signal, if any. */ -function requestSignal(timeoutMs: number, caller?: AbortSignal): AbortSignal | undefined { - const timeout = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined; - if (timeout === undefined) return caller; - if (caller === undefined) return timeout; - return AbortSignal.any([caller, timeout]); -} - -/** Per-call resilience: a timeout deadline and an optional retry policy. */ -export interface ResilienceOptions { - /** Per-attempt deadline in milliseconds; `0` disables it. */ - timeoutMs: number; - /** Retry policy for transient failures. Omitted means a single attempt. */ - retry?: Policy | undefined; - /** A caller abort signal, honored across attempts (including the backoff sleep). */ - signal?: AbortSignal | undefined; -} - -/** - * Runs a vendor `fetch` with a per-attempt timeout and, when a policy is supplied, retries on - * transient failures (network/timeout errors and 429/5xx responses). A non-retryable response - * (a 4xx, or any 2xx) is returned unchanged for the provider's normal handling. Each attempt gets - * a fresh timeout window; the caller's `signal` cancels the whole loop, backoff included. - * - * Note: an email send is not idempotent, so retry is opt-in (off unless configured) — enabling it - * accepts that an ambiguous failure (timeout/5xx after the vendor accepted the message) may - * double-deliver. - */ -export async function resilientFetch( - doFetch: (signal: AbortSignal | undefined) => Promise, - opts: ResilienceOptions, -): Promise { - const attempt = async (): Promise => { - const response = await doFetch(requestSignal(opts.timeoutMs, opts.signal)); - if (opts.retry !== undefined && !response.ok && isRetryableStatus(response.status)) { - throw new RetryableResponseError(response); - } - return response; - }; - - if (opts.retry === undefined) { - return attempt(); - } - try { - return await opts.retry.run(attempt, { signal: opts.signal }); - } catch (err) { - if (err instanceof RetryableResponseError) { - // Retries exhausted on a retryable status: hand the final response back so the provider - // logs the vendor's status/body rather than an opaque marker error. - return err.response; - } - throw err; - } -} - -/** Builds a retry {@link Policy} from config, wiring the provider's logger for retry log lines. */ -export function retryPolicy( - config: RetryConfig | undefined, - logger: Logger, -): Policy | undefined { - return config === undefined ? undefined : exponentialBackoff(config, { logger }); -} - -/** Resolves the `fetch` to use: an injected one, else the global, else an explanatory throw. */ -export function resolveFetch(fetchImpl?: FetchLike): FetchLike { - if (fetchImpl !== undefined) return fetchImpl; - if (typeof globalThis.fetch === "function") { - // Bind so the global fetch keeps its `this` when called as a bare reference. - return globalThis.fetch.bind(globalThis); - } - throw new Error("no fetch implementation available; pass one via options.fetch"); -} - -/** - * Parses a response body as JSON, returning `undefined` when the body is empty or not valid - * JSON instead of throwing. A vendor can answer a successful send with an empty or non-JSON 2xx - * body; a message the vendor accepted must not surface as a failure just because we couldn't - * read an id out of the response. - */ -export async function parseJsonBody(response: Response): Promise { - const text = await response.text(); - if (text.trim() === "") { - return undefined; - } - try { - return JSON.parse(text) as T; - } catch { - return undefined; - } -} - -/** Normalizes recipients to an array. */ -export function recipientList(recipients: Recipients): string[] { - return Array.isArray(recipients) ? recipients : [recipients]; -} - -/** The domain of the first recipient (the part after `@`), attached to error logs. */ -export function recipientDomain(recipients: Recipients): string { - const first = recipientList(recipients)[0] ?? ""; - const at = first.lastIndexOf("@"); - return at === -1 ? "" : first.slice(at + 1); -} - -/** The header names vendors use to carry a per-request id, in the order we prefer them. */ -const requestIdHeaderNames = ["x-message-id", "x-request-id"]; - -/** Reads the vendor's request/message id from a response, trying the common header names. */ -export function requestIdFromHeaders(headers: Headers): string | undefined { - for (const name of requestIdHeaderNames) { - const value = headers.get(name); - if (value !== null && value !== "") { - return value; - } - } - return undefined; -} diff --git a/packages/email/src/providers/mailgun.ts b/packages/email/src/providers/mailgun.ts deleted file mode 100644 index 8afd81d..0000000 --- a/packages/email/src/providers/mailgun.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import type { Policy, RetryConfig } from "@primandproper/retry"; - -import { - assertHasBody, - type Email, - type EmailMessage, - type Recipients, - type SendResult, -} from "../email.js"; -import { senderInstruments, type SenderInstruments } from "../support.js"; - -import { - DEFAULT_EMAIL_TIMEOUT_MS, - recipientDomain, - parseJsonBody, - recipientList, - requestIdFromHeaders, - resilientFetch, - resolveFetch, - retryPolicy, - type FetchLike, -} from "./http.js"; - -const o11yName = "email"; - -export interface MailgunEmailOptions { - /** The Mailgun private API key (used as the password in HTTP basic auth, user `api`). */ - apiKey: string; - /** The sending domain, e.g. `mg.example.com`. */ - domain: string; - /** Overrides the API base URL. Defaults to `https://api.mailgun.net` (use `api.eu.mailgun.net` for EU). */ - baseUrl?: string; - /** Per-send deadline in milliseconds; `0` disables it. Defaults to 30s. */ - timeoutMs?: number; - /** Optional retry policy for transient failures (network/timeout, 429/5xx). Off by default. */ - retry?: RetryConfig | undefined; - /** The `fetch` implementation. Defaults to `globalThis.fetch`. */ - fetch?: FetchLike; -} - -interface MailgunSendResponse { - id?: string; - message?: string; -} - -/** - * An {@link Email} backed by the Mailgun messages API. POSTs a form-encoded body to - * `/v3//messages` with HTTP basic auth (`api:`). The returned message id becomes the - * {@link SendResult.id}. A non-2xx response throws an Error carrying the status and body text. - */ -export class MailgunEmail implements Email { - readonly #authorization: string; - readonly #domain: string; - readonly #baseUrl: string; - readonly #fetch: FetchLike; - readonly #timeoutMs: number; - readonly #retry: Policy | undefined; - readonly #observer: Observer; - readonly #instruments: SenderInstruments; - - constructor(options: MailgunEmailOptions, deps: ObservabilityDeps = {}) { - this.#authorization = `Basic ${btoa(`api:${options.apiKey}`)}`; - this.#domain = options.domain; - this.#baseUrl = options.baseUrl ?? "https://api.mailgun.net"; - this.#fetch = resolveFetch(options.fetch); - this.#timeoutMs = options.timeoutMs ?? DEFAULT_EMAIL_TIMEOUT_MS; - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#retry = retryPolicy(options.retry, this.#observer.logger()); - this.#instruments = senderInstruments(o11yName, deps); - } - - send(message: EmailMessage): Promise { - return this.#observer.run("send", async (op) => { - assertHasBody(message); - op.set("recipientDomain", recipientDomain(message.to)); - - const response = await resilientFetch( - (signal) => - this.#fetch(`${this.#baseUrl}/v3/${this.#domain}/messages`, { - method: "POST", - headers: { - authorization: this.#authorization, - "content-type": "application/x-www-form-urlencoded", - }, - body: toMailgunForm(message).toString(), - ...(signal === undefined ? {} : { signal }), - }), - { timeoutMs: this.#timeoutMs, retry: this.#retry }, - ); - - if (!response.ok) { - const body = await response.text(); - const requestId = requestIdFromHeaders(response.headers); - if (requestId !== undefined) { - op.set("requestId", requestId); - } - this.#instruments.errors.add(1); - throw op.error( - new Error(`mailgun send failed: ${String(response.status)} ${body}`), - `mailgun send failed with status ${String(response.status)}`, - ); - } - - const parsed = (await parseJsonBody(response)) ?? {}; - if (parsed.id !== undefined) { - op.set("requestId", parsed.id); - } - this.#instruments.sends.add(1); - return parsed.id === undefined ? {} : { id: parsed.id }; - }); - } - - async ping(): Promise { - const response = await this.#fetch(`${this.#baseUrl}/v3/domains`, { - method: "GET", - headers: { authorization: this.#authorization }, - }); - if (!response.ok) { - const body = await response.text(); - throw new Error(`mailgun ping failed: ${String(response.status)} ${body}`); - } - } -} - -function appendRecipients( - form: URLSearchParams, - field: string, - recipients: Recipients, -): void { - for (const address of recipientList(recipients)) { - form.append(field, address); - } -} - -/** Maps an {@link EmailMessage} to a Mailgun form-encoded body. */ -function toMailgunForm(message: EmailMessage): URLSearchParams { - const form = new URLSearchParams(); - form.set("from", message.from); - appendRecipients(form, "to", message.to); - form.set("subject", message.subject); - if (message.text !== undefined) form.set("text", message.text); - if (message.html !== undefined) form.set("html", message.html); - if (message.cc !== undefined) appendRecipients(form, "cc", message.cc); - if (message.bcc !== undefined) appendRecipients(form, "bcc", message.bcc); - if (message.replyTo !== undefined) form.set("h:Reply-To", message.replyTo); - return form; -} diff --git a/packages/email/src/providers/mailjet.ts b/packages/email/src/providers/mailjet.ts deleted file mode 100644 index 77fc945..0000000 --- a/packages/email/src/providers/mailjet.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import type { Policy, RetryConfig } from "@primandproper/retry"; - -import { - assertHasBody, - type Email, - type EmailMessage, - type Recipients, - type SendResult, -} from "../email.js"; -import { senderInstruments, type SenderInstruments } from "../support.js"; - -import { - DEFAULT_EMAIL_TIMEOUT_MS, - parseJsonBody, - recipientDomain, - recipientList, - requestIdFromHeaders, - resilientFetch, - resolveFetch, - retryPolicy, - type FetchLike, -} from "./http.js"; - -const o11yName = "email"; - -export interface MailjetEmailOptions { - /** The Mailjet API key (basic-auth username). */ - apiKey: string; - /** The Mailjet secret key (basic-auth password). */ - secretKey: string; - /** Overrides the API base URL. Defaults to `https://api.mailjet.com`. */ - baseUrl?: string; - /** Per-send deadline in milliseconds; `0` disables it. Defaults to 30s. */ - timeoutMs?: number; - /** Optional retry policy for transient failures (network/timeout, 429/5xx). Off by default. */ - retry?: RetryConfig | undefined; - /** The `fetch` implementation. Defaults to `globalThis.fetch`. */ - fetch?: FetchLike; -} - -interface MailjetSendResponse { - Messages?: { To?: { MessageID?: number; MessageUUID?: string }[] }[]; -} - -/** - * An {@link Email} backed by the Mailjet Send API v3.1. POSTs to `/v3.1/send` with HTTP basic auth - * (`apiKey:secretKey`). The first recipient's `MessageUUID` (or `MessageID`) becomes the - * {@link SendResult.id}. A non-2xx response throws an Error carrying the status and body text. - */ -export class MailjetEmail implements Email { - readonly #authorization: string; - readonly #baseUrl: string; - readonly #fetch: FetchLike; - readonly #timeoutMs: number; - readonly #retry: Policy | undefined; - readonly #observer: Observer; - readonly #instruments: SenderInstruments; - - constructor(options: MailjetEmailOptions, deps: ObservabilityDeps = {}) { - this.#authorization = `Basic ${btoa(`${options.apiKey}:${options.secretKey}`)}`; - this.#baseUrl = options.baseUrl ?? "https://api.mailjet.com"; - this.#fetch = resolveFetch(options.fetch); - this.#timeoutMs = options.timeoutMs ?? DEFAULT_EMAIL_TIMEOUT_MS; - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#retry = retryPolicy(options.retry, this.#observer.logger()); - this.#instruments = senderInstruments(o11yName, deps); - } - - send(message: EmailMessage): Promise { - return this.#observer.run("send", async (op) => { - assertHasBody(message); - op.set("recipientDomain", recipientDomain(message.to)); - - const response = await resilientFetch( - (signal) => - this.#fetch(`${this.#baseUrl}/v3.1/send`, { - method: "POST", - headers: { - authorization: this.#authorization, - "content-type": "application/json", - }, - body: JSON.stringify(toMailjetPayload(message)), - ...(signal === undefined ? {} : { signal }), - }), - { timeoutMs: this.#timeoutMs, retry: this.#retry }, - ); - - if (!response.ok) { - const body = await response.text(); - const requestId = requestIdFromHeaders(response.headers); - if (requestId !== undefined) { - op.set("requestId", requestId); - } - this.#instruments.errors.add(1); - throw op.error( - new Error(`mailjet send failed: ${String(response.status)} ${body}`), - `mailjet send failed with status ${String(response.status)}`, - ); - } - - const parsed = (await parseJsonBody(response)) ?? {}; - const recipient = parsed.Messages?.[0]?.To?.[0]; - const id = - recipient?.MessageUUID ?? - (recipient?.MessageID !== undefined ? String(recipient.MessageID) : undefined); - if (id !== undefined) { - op.set("requestId", id); - } - this.#instruments.sends.add(1); - return id === undefined ? {} : { id }; - }); - } - - async ping(): Promise { - const response = await this.#fetch(`${this.#baseUrl}/v3/REST/sender`, { - method: "GET", - headers: { authorization: this.#authorization }, - }); - if (!response.ok) { - const body = await response.text(); - throw new Error(`mailjet ping failed: ${String(response.status)} ${body}`); - } - } -} - -interface MailjetAddress { - Email: string; -} - -interface MailjetMessage { - From: MailjetAddress; - To: MailjetAddress[]; - Subject: string; - TextPart?: string; - HTMLPart?: string; - Cc?: MailjetAddress[]; - Bcc?: MailjetAddress[]; - ReplyTo?: MailjetAddress; -} - -function toAddresses(recipients: Recipients): MailjetAddress[] { - return recipientList(recipients).map((email) => ({ Email: email })); -} - -/** Maps an {@link EmailMessage} to the Mailjet Send v3.1 request body. */ -function toMailjetPayload(message: EmailMessage): { Messages: MailjetMessage[] } { - const entry: MailjetMessage = { - From: { Email: message.from }, - To: toAddresses(message.to), - Subject: message.subject, - }; - if (message.text !== undefined) entry.TextPart = message.text; - if (message.html !== undefined) entry.HTMLPart = message.html; - if (message.cc !== undefined) entry.Cc = toAddresses(message.cc); - if (message.bcc !== undefined) entry.Bcc = toAddresses(message.bcc); - if (message.replyTo !== undefined) entry.ReplyTo = { Email: message.replyTo }; - return { Messages: [entry] }; -} diff --git a/packages/email/src/providers/memory.ts b/packages/email/src/providers/memory.ts deleted file mode 100644 index d753f82..0000000 --- a/packages/email/src/providers/memory.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - assertHasBody, - type Email, - type EmailMessage, - type SendResult, -} from "../email.js"; - -/** - * An {@link Email} that captures every send in memory rather than delivering it. Useful for - * tests: assert against {@link MemoryEmail.sent}. Universal logic, but the package is - * server-only by modality. - */ -export class MemoryEmail implements Email { - /** Every message passed to {@link send}, in order. Readable for assertions. */ - readonly sent: EmailMessage[] = []; - - async send(message: EmailMessage): Promise { - assertHasBody(message); - this.sent.push(message); - return { id: String(this.sent.length) }; - } - - ping(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/email/src/providers/noop.ts b/packages/email/src/providers/noop.ts deleted file mode 100644 index aef9071..0000000 --- a/packages/email/src/providers/noop.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - makeObserver, - type Logger, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { - assertHasBody, - type Email, - type EmailMessage, - type SendResult, -} from "../email.js"; - -const o11yName = "email"; - -/** An {@link Email} that delivers nothing; it logs at debug and returns an empty result. */ -export class NoopEmail implements Email { - readonly #observer: Observer; - readonly #logger: Logger; - - constructor(deps: ObservabilityDeps = {}) { - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#logger = this.#observer.logger(); - } - - async send(message: EmailMessage): Promise { - assertHasBody(message); - this.#logger.debug("email send is a noop"); - return {}; - } - - ping(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/email/src/providers/postmark.node.ts b/packages/email/src/providers/postmark.node.ts deleted file mode 100644 index 7a7dad2..0000000 --- a/packages/email/src/providers/postmark.node.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { wrap } from "@primandproper/errors"; -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import { ServerClient } from "postmark"; - -import { - assertHasBody, - type Email, - type EmailMessage, - type SendResult, -} from "../email.js"; -import { senderInstruments, type SenderInstruments } from "../support.js"; - -import { recipientDomain } from "./http.js"; - -const o11yName = "email"; - -/** The slice of the Postmark `ServerClient` the provider relies on. Injectable for tests. */ -export interface PostmarkClientLike { - sendEmail(message: PostmarkMessage): Promise; - getServer(): Promise; -} - -export interface PostmarkEmailOptions { - /** The Postmark server API token, passed to the `ServerClient`. */ - serverToken: string; - /** - * The Postmark client. Defaults to a real `ServerClient` built from `serverToken`. Inject - * a fake in tests so no network call is made. - */ - client?: PostmarkClientLike; -} - -/** The subset of Postmark's send-email request we populate. Postmark uses PascalCase fields. */ -export interface PostmarkMessage { - From: string; - To: string; - Subject: string; - TextBody?: string; - HtmlBody?: string; - Cc?: string; - Bcc?: string; - ReplyTo?: string; -} - -/** The slice of Postmark's send-email response we read. */ -export interface PostmarkSendResponse { - MessageID?: string; - ErrorCode?: number; - Message?: string; -} - -/** - * An {@link Email} backed by the Postmark `ServerClient` SDK. A non-zero `ErrorCode` in the - * response, or any error thrown by the SDK, is wrapped in an Error carrying Postmark's context. - */ -export class PostmarkEmail implements Email { - readonly #client: PostmarkClientLike; - readonly #observer: Observer; - readonly #instruments: SenderInstruments; - - constructor(options: PostmarkEmailOptions, deps: ObservabilityDeps = {}) { - this.#client = options.client ?? new ServerClient(options.serverToken); - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#instruments = senderInstruments(o11yName, deps); - } - - send(message: EmailMessage): Promise { - return this.#observer.run("send", async (op) => { - assertHasBody(message); - op.set("recipientDomain", recipientDomain(message.to)); - - let response: PostmarkSendResponse; - try { - response = await this.#client.sendEmail(toPostmarkMessage(message)); - } catch (cause) { - this.#instruments.errors.add(1); - throw op.error(wrap("postmark send failed", cause), "postmark send failed"); - } - - if (response.ErrorCode !== undefined && response.ErrorCode !== 0) { - const detail = response.Message ?? "no message"; - this.#instruments.errors.add(1); - throw op.error( - new Error(`postmark send failed: ${String(response.ErrorCode)} ${detail}`), - `postmark send returned error code ${String(response.ErrorCode)}`, - ); - } - - if (response.MessageID !== undefined) { - op.set("requestId", response.MessageID); - } - this.#instruments.sends.add(1); - return response.MessageID === undefined ? {} : { id: response.MessageID }; - }); - } - - async ping(): Promise { - try { - await this.#client.getServer(); - } catch (cause) { - throw wrap("postmark ping failed", cause); - } - } -} - -/** Recipient lists are arrays or single strings; Postmark wants a comma-separated string. */ -function joinRecipients(recipients: EmailMessage["to"]): string { - return Array.isArray(recipients) ? recipients.join(", ") : recipients; -} - -/** Maps an {@link EmailMessage} to Postmark's send-email request body. */ -function toPostmarkMessage(message: EmailMessage): PostmarkMessage { - const payload: PostmarkMessage = { - From: message.from, - To: joinRecipients(message.to), - Subject: message.subject, - }; - if (message.text !== undefined) { - payload.TextBody = message.text; - } - if (message.html !== undefined) { - payload.HtmlBody = message.html; - } - if (message.cc !== undefined) { - payload.Cc = joinRecipients(message.cc); - } - if (message.bcc !== undefined) { - payload.Bcc = joinRecipients(message.bcc); - } - if (message.replyTo !== undefined) { - payload.ReplyTo = message.replyTo; - } - return payload; -} diff --git a/packages/email/src/providers/resend.ts b/packages/email/src/providers/resend.ts deleted file mode 100644 index ed03a2a..0000000 --- a/packages/email/src/providers/resend.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import type { Policy, RetryConfig } from "@primandproper/retry"; - -import { - assertHasBody, - type Email, - type EmailMessage, - type SendResult, -} from "../email.js"; -import { senderInstruments, type SenderInstruments } from "../support.js"; - -import { - DEFAULT_EMAIL_TIMEOUT_MS, - type FetchLike, - parseJsonBody, - recipientDomain, - requestIdFromHeaders, - resilientFetch, - retryPolicy, -} from "./http.js"; - -const o11yName = "email"; - -export interface ResendEmailOptions { - /** The Resend API key, sent as a bearer token. */ - apiKey: string; - /** Overrides the API base URL. Defaults to `https://api.resend.com`. */ - baseUrl?: string; - /** Per-send deadline in milliseconds; `0` disables it. Defaults to 30s. */ - timeoutMs?: number; - /** Optional retry policy for transient failures (network/timeout, 429/5xx). Off by default. */ - retry?: RetryConfig | undefined; - /** The `fetch` implementation. Defaults to `globalThis.fetch`. */ - fetch?: FetchLike; -} - -/** The shape of the Resend create-email response we read. */ -interface ResendSendResponse { - id?: string; -} - -/** - * An {@link Email} backed by the Resend REST API. Zero-dependency: it POSTs to - * `/emails` with the global `fetch` and a bearer token. A non-2xx response throws an Error - * carrying the status and body text. - */ -export class ResendEmail implements Email { - readonly #apiKey: string; - readonly #baseUrl: string; - readonly #fetch: FetchLike; - readonly #timeoutMs: number; - readonly #retry: Policy | undefined; - readonly #observer: Observer; - readonly #instruments: SenderInstruments; - - constructor(options: ResendEmailOptions, deps: ObservabilityDeps = {}) { - this.#apiKey = options.apiKey; - this.#baseUrl = options.baseUrl ?? "https://api.resend.com"; - if (options.fetch !== undefined) { - this.#fetch = options.fetch; - } else if (typeof globalThis.fetch === "function") { - // Bind so the global fetch keeps its `this` when called as a bare reference. - this.#fetch = globalThis.fetch.bind(globalThis); - } else { - throw new Error("no fetch implementation available; pass one via options.fetch"); - } - this.#timeoutMs = options.timeoutMs ?? DEFAULT_EMAIL_TIMEOUT_MS; - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#retry = retryPolicy(options.retry, this.#observer.logger()); - this.#instruments = senderInstruments(o11yName, deps); - } - - send(message: EmailMessage): Promise { - return this.#observer.run("send", async (op) => { - assertHasBody(message); - op.set("recipientDomain", recipientDomain(message.to)); - - const response = await resilientFetch( - (signal) => - this.#fetch(`${this.#baseUrl}/emails`, { - method: "POST", - headers: { - authorization: `Bearer ${this.#apiKey}`, - "content-type": "application/json", - }, - body: JSON.stringify(toResendPayload(message)), - ...(signal === undefined ? {} : { signal }), - }), - { timeoutMs: this.#timeoutMs, retry: this.#retry }, - ); - - if (!response.ok) { - const body = await response.text(); - const requestId = requestIdFromHeaders(response.headers); - if (requestId !== undefined) { - op.set("requestId", requestId); - } - this.#instruments.errors.add(1); - throw op.error( - new Error(`resend send failed: ${String(response.status)} ${body}`), - `resend send failed with status ${String(response.status)}`, - ); - } - - const parsed = (await parseJsonBody(response)) ?? {}; - if (parsed.id !== undefined) { - op.set("requestId", parsed.id); - } - this.#instruments.sends.add(1); - return parsed.id === undefined ? {} : { id: parsed.id }; - }); - } - - async ping(): Promise { - // A real authenticated health check: GET /domains exercises the API key against a documented - // read-only endpoint (the old OPTIONS /emails probe hit an undocumented, unauthenticated path - // that could "succeed" with an invalid key). - const response = await this.#fetch(`${this.#baseUrl}/domains`, { - method: "GET", - headers: { authorization: `Bearer ${this.#apiKey}` }, - }); - if (!response.ok) { - const body = await response.text(); - throw new Error(`resend ping failed: ${String(response.status)} ${body}`); - } - } -} - -/** The Resend create-email request body. `reply_to` is Resend's snake_case spelling. */ -interface ResendPayload { - to: EmailMessage["to"]; - from: string; - subject: string; - text?: string; - html?: string; - cc?: EmailMessage["cc"]; - bcc?: EmailMessage["bcc"]; - reply_to?: string; -} - -/** Maps an {@link EmailMessage} to the Resend create-email request body. */ -function toResendPayload(message: EmailMessage): ResendPayload { - const payload: ResendPayload = { - to: message.to, - from: message.from, - subject: message.subject, - }; - if (message.text !== undefined) { - payload.text = message.text; - } - if (message.html !== undefined) { - payload.html = message.html; - } - if (message.cc !== undefined) { - payload.cc = message.cc; - } - if (message.bcc !== undefined) { - payload.bcc = message.bcc; - } - if (message.replyTo !== undefined) { - payload.reply_to = message.replyTo; - } - return payload; -} diff --git a/packages/email/src/providers/sendgrid.ts b/packages/email/src/providers/sendgrid.ts deleted file mode 100644 index abd5ca1..0000000 --- a/packages/email/src/providers/sendgrid.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import type { Policy, RetryConfig } from "@primandproper/retry"; - -import { - assertHasBody, - type Email, - type EmailMessage, - type Recipients, - type SendResult, -} from "../email.js"; -import { senderInstruments, type SenderInstruments } from "../support.js"; - -import { - DEFAULT_EMAIL_TIMEOUT_MS, - recipientDomain, - recipientList, - requestIdFromHeaders, - resilientFetch, - resolveFetch, - retryPolicy, - type FetchLike, -} from "./http.js"; - -const o11yName = "email"; - -export interface SendgridEmailOptions { - /** The SendGrid API key, sent as a bearer token. */ - apiKey: string; - /** Overrides the API base URL. Defaults to `https://api.sendgrid.com`. */ - baseUrl?: string; - /** Per-send deadline in milliseconds; `0` disables it. Defaults to 30s. */ - timeoutMs?: number; - /** Optional retry policy for transient failures (network/timeout, 429/5xx). Off by default. */ - retry?: RetryConfig | undefined; - /** The `fetch` implementation. Defaults to `globalThis.fetch`. */ - fetch?: FetchLike; -} - -/** - * An {@link Email} backed by the SendGrid v3 Mail Send API. POSTs to `/v3/mail/send` with a bearer - * token. On success SendGrid returns `202` with no body and an `X-Message-Id` header, which becomes - * the {@link SendResult.id}. A non-2xx response throws an Error carrying the status and body text. - */ -export class SendgridEmail implements Email { - readonly #apiKey: string; - readonly #baseUrl: string; - readonly #fetch: FetchLike; - readonly #timeoutMs: number; - readonly #retry: Policy | undefined; - readonly #observer: Observer; - readonly #instruments: SenderInstruments; - - constructor(options: SendgridEmailOptions, deps: ObservabilityDeps = {}) { - this.#apiKey = options.apiKey; - this.#baseUrl = options.baseUrl ?? "https://api.sendgrid.com"; - this.#fetch = resolveFetch(options.fetch); - this.#timeoutMs = options.timeoutMs ?? DEFAULT_EMAIL_TIMEOUT_MS; - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#retry = retryPolicy(options.retry, this.#observer.logger()); - this.#instruments = senderInstruments(o11yName, deps); - } - - send(message: EmailMessage): Promise { - return this.#observer.run("send", async (op) => { - assertHasBody(message); - op.set("recipientDomain", recipientDomain(message.to)); - - const response = await resilientFetch( - (signal) => - this.#fetch(`${this.#baseUrl}/v3/mail/send`, { - method: "POST", - headers: { - authorization: `Bearer ${this.#apiKey}`, - "content-type": "application/json", - }, - body: JSON.stringify(toSendgridPayload(message)), - ...(signal === undefined ? {} : { signal }), - }), - { timeoutMs: this.#timeoutMs, retry: this.#retry }, - ); - - if (!response.ok) { - const body = await response.text(); - const requestId = requestIdFromHeaders(response.headers); - if (requestId !== undefined) { - op.set("requestId", requestId); - } - this.#instruments.errors.add(1); - throw op.error( - new Error(`sendgrid send failed: ${String(response.status)} ${body}`), - `sendgrid send failed with status ${String(response.status)}`, - ); - } - - const id = response.headers.get("x-message-id"); - if (id !== null) { - op.set("requestId", id); - } - this.#instruments.sends.add(1); - return id === null ? {} : { id }; - }); - } - - async ping(): Promise { - const response = await this.#fetch(`${this.#baseUrl}/v3/scopes`, { - method: "GET", - headers: { authorization: `Bearer ${this.#apiKey}` }, - }); - if (!response.ok) { - const body = await response.text(); - throw new Error(`sendgrid ping failed: ${String(response.status)} ${body}`); - } - } -} - -interface SendgridAddress { - email: string; -} - -interface SendgridPersonalization { - to: SendgridAddress[]; - cc?: SendgridAddress[]; - bcc?: SendgridAddress[]; -} - -interface SendgridContent { - type: string; - value: string; -} - -interface SendgridPayload { - personalizations: SendgridPersonalization[]; - from: SendgridAddress; - subject: string; - content: SendgridContent[]; - reply_to?: SendgridAddress; -} - -function toAddresses(recipients: Recipients): SendgridAddress[] { - return recipientList(recipients).map((email) => ({ email })); -} - -/** Maps an {@link EmailMessage} to the SendGrid Mail Send request body. */ -function toSendgridPayload(message: EmailMessage): SendgridPayload { - const personalization: SendgridPersonalization = { to: toAddresses(message.to) }; - if (message.cc !== undefined) personalization.cc = toAddresses(message.cc); - if (message.bcc !== undefined) personalization.bcc = toAddresses(message.bcc); - - const content: SendgridContent[] = []; - if (message.text !== undefined) - content.push({ type: "text/plain", value: message.text }); - if (message.html !== undefined) - content.push({ type: "text/html", value: message.html }); - - const payload: SendgridPayload = { - personalizations: [personalization], - from: { email: message.from }, - subject: message.subject, - content, - }; - if (message.replyTo !== undefined) payload.reply_to = { email: message.replyTo }; - return payload; -} diff --git a/packages/email/src/support.ts b/packages/email/src/support.ts deleted file mode 100644 index 12b83f4..0000000 --- a/packages/email/src/support.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - makeMetrics, - type Metrics, - type ObservabilityDeps, -} from "@primandproper/observability"; - -type Counter = ReturnType; - -/** - * The `_sends` / `_errors` counter pair every Go sender mints in its constructor - * (`metrics.EnsureMetricsProvider(...).NewInt64Counter(o11yName + "_sends"/"_errors")`). The - * meter is registered under the component's o11y name so instruments group with its spans/logs. - */ -export interface SenderInstruments { - sends: Counter; - errors: Counter; -} - -/** Builds the `{o11yName}_sends` / `{o11yName}_errors` counters, defaulting to the noop meter. */ -export function senderInstruments( - o11yName: string, - deps: ObservabilityDeps | undefined, -): SenderInstruments { - const metrics = makeMetrics(o11yName, deps?.metrics); - return { - sends: metrics.counter(`${o11yName}_sends`), - errors: metrics.counter(`${o11yName}_errors`), - }; -} diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json deleted file mode 100644 index bdb9327..0000000 --- a/packages/email/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022"], - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/email/tsup.config.ts b/packages/email/tsup.config.ts deleted file mode 100644 index 8bf2394..0000000 --- a/packages/email/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { index: "src/index.ts" }, - platform: "node", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: true, -}); diff --git a/packages/healthcheck/CHANGELOG.md b/packages/healthcheck/CHANGELOG.md deleted file mode 100644 index abe623d..0000000 --- a/packages/healthcheck/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -# @primandproper/healthcheck - -## 0.0.2 - -### Patch Changes - -- a124406: Add a per-check timeout via `HealthRegistryOptions` and propagate aborts instead of reporting unhealthy. -- Updated dependencies [a124406] - - @primandproper/observability@0.1.0 diff --git a/packages/healthcheck/package.json b/packages/healthcheck/package.json deleted file mode 100644 index 2ee20ab..0000000 --- a/packages/healthcheck/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@primandproper/healthcheck", - "version": "0.0.2", - "type": "module", - "description": "Server-only health checks: Checker and Registry aggregating component health.", - "license": "AGPL-3.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/primandproper/platform-ts.git", - "directory": "packages/healthcheck" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "eslint .", - "clean": "rm -rf dist .turbo" - }, - "dependencies": { - "@primandproper/observability": "workspace:*" - } -} diff --git a/packages/healthcheck/src/healthcheck.test.ts b/packages/healthcheck/src/healthcheck.test.ts deleted file mode 100644 index 7bd0b85..0000000 --- a/packages/healthcheck/src/healthcheck.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { makeRecordingObserver, type Logger } from "@primandproper/observability"; -import { describe, expect, it, vi } from "vitest"; - -import { checker, HealthRegistry, noopChecker, type CheckResult } from "./index.js"; - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** A logger whose `warn`/`error` messages are recorded; chainable methods return itself. */ -function recordingLogger(): { logger: Logger; warns: string[]; errors: string[] } { - const warns: string[] = []; - const errors: string[] = []; - const logger: Logger = { - debug: vi.fn(), - info: vi.fn(), - warn: (message) => { - warns.push(message); - }, - error: (message) => { - errors.push(message); - }, - with: () => logger, - child: () => logger, - withSpan: () => logger, - }; - return { logger, warns, errors }; -} - -describe("checker", () => { - it("maps a resolving function to healthy with a measured duration", async () => { - const result = await checker("ok", () => Promise.resolve()).check(); - expect(result.status).toBe("healthy"); - expect(result.durationMs).toBeGreaterThanOrEqual(0); - expect(result.error).toBeUndefined(); - }); - - it("maps a thrown error to unhealthy carrying the message", async () => { - const result = await checker("boom", () => - Promise.reject(new Error("db unreachable")), - ).check(); - expect(result.status).toBe("unhealthy"); - expect(result.error).toBe("db unreachable"); - }); - - it("honors a Partial return", async () => { - const result = await checker("slow", () => - Promise.resolve>({ status: "degraded", detail: "lagging" }), - ).check(); - expect(result.status).toBe("degraded"); - expect(result.detail).toBe("lagging"); - expect(result.durationMs).toBeGreaterThanOrEqual(0); - }); - - it("reports unhealthy when the function exceeds timeoutMs", async () => { - const result = await checker("hang", () => delay(50), { timeoutMs: 10 }).check(); - expect(result.status).toBe("unhealthy"); - expect(result.error).toMatch(/timed out/); - }); - - it("noopChecker is always healthy", async () => { - const result = await noopChecker("placeholder").check(); - expect(result.status).toBe("healthy"); - }); - - it("propagates a caller-initiated abort instead of reporting unhealthy (HC-2)", async () => { - const abortable = checker( - "abortable", - (signal) => - new Promise((_resolve, reject) => { - if (signal?.aborted) { - reject(signal.reason as Error); - return; - } - signal?.addEventListener( - "abort", - () => { - reject(signal.reason as Error); - }, - { once: true }, - ); - }), - ); - const controller = new AbortController(); - const pending = abortable.check(controller.signal); - controller.abort(); - // The cancellation surfaces as an AbortError rather than a false "unhealthy" component. - await expect(pending).rejects.toHaveProperty("name", "AbortError"); - }); -}); - -describe("HealthRegistry", () => { - it("reports healthy for an empty registry", async () => { - const report = await new HealthRegistry().check(); - expect(report.status).toBe("healthy"); - expect(report.checks).toEqual({}); - }); - - it("is healthy when every check is healthy", async () => { - const registry = new HealthRegistry(); - registry.register(noopChecker("a")); - registry.register(noopChecker("b")); - const report = await registry.check(); - expect(report.status).toBe("healthy"); - expect(Object.keys(report.checks)).toEqual(["a", "b"]); - }); - - it("is degraded when a check is degraded but none are unhealthy", async () => { - const registry = new HealthRegistry(); - registry.register(noopChecker("a")); - registry.register( - checker("b", () => Promise.resolve>({ status: "degraded" })), - ); - const report = await registry.check(); - expect(report.status).toBe("degraded"); - }); - - it("is unhealthy when any check is unhealthy", async () => { - const registry = new HealthRegistry(); - registry.register( - checker("b", () => Promise.resolve>({ status: "degraded" })), - ); - registry.register(checker("c", () => Promise.reject(new Error("down")))); - const report = await registry.check(); - expect(report.status).toBe("unhealthy"); - expect(report.checks.c?.status).toBe("unhealthy"); - }); - - it("keys the report by every checker's name", async () => { - const registry = new HealthRegistry(); - registry.register(noopChecker("alpha")); - registry.register(noopChecker("beta")); - const report = await registry.check(); - expect(Object.keys(report.checks).sort()).toEqual(["alpha", "beta"]); - }); - - it("last-wins on a duplicate name", async () => { - const registry = new HealthRegistry(); - registry.register(noopChecker("dup")); - registry.register(checker("dup", () => Promise.reject(new Error("replaced")))); - expect(registry.checkers()).toHaveLength(1); - const report = await registry.check(); - expect(report.checks.dup?.status).toBe("unhealthy"); - }); - - it("runs checks concurrently", async () => { - const registry = new HealthRegistry(); - registry.register(checker("a", () => delay(40))); - registry.register(checker("b", () => delay(40))); - registry.register(checker("c", () => delay(40))); - const start = performance.now(); - const report = await registry.check(); - const elapsed = performance.now() - start; - expect(report.status).toBe("healthy"); - expect(elapsed).toBeLessThan(120); - }); - - // HC-1: a checker built without its own timeoutMs must not hang the whole report forever. - it("bounds a hanging checker with the registry deadline", async () => { - const registry = new HealthRegistry({}, { checkTimeoutMs: 20 }); - registry.register(noopChecker("fast")); - registry.register(checker("hang", () => new Promise(() => undefined))); // never resolves - - const start = performance.now(); - const report = await registry.check(); - const elapsed = performance.now() - start; - - expect(report.status).toBe("unhealthy"); - expect(report.checks.hang?.status).toBe("unhealthy"); - expect(report.checks.hang?.error).toMatch(/registry deadline/); - expect(report.checks.fast?.status).toBe("healthy"); - expect(elapsed).toBeLessThan(1_000); // did not hang on the never-resolving checker - }); - - it("can disable the registry deadline with checkTimeoutMs: 0", async () => { - const registry = new HealthRegistry({}, { checkTimeoutMs: 0 }); - registry.register(checker("slow", () => delay(30))); - const report = await registry.check(); - expect(report.status).toBe("healthy"); // 30ms check is not cut off - }); - - // INST-2: a failing component must be visible in the logs, not silent. - it("logs an unhealthy check at error naming the check", async () => { - const { logger, warns, errors } = recordingLogger(); - const registry = new HealthRegistry({ logger }); - registry.register(noopChecker("ok")); - registry.register(checker("db", () => Promise.reject(new Error("db unreachable")))); - await registry.check(); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain("db"); - expect(errors[0]).toContain("unhealthy"); - expect(warns).toHaveLength(0); - }); - - it("logs a degraded check at warn naming the check", async () => { - const { logger, warns, errors } = recordingLogger(); - const registry = new HealthRegistry({ logger }); - registry.register( - checker("cache", () => - Promise.resolve>({ status: "degraded", detail: "lagging" }), - ), - ); - await registry.check(); - expect(warns).toHaveLength(1); - expect(warns[0]).toContain("cache"); - expect(warns[0]).toContain("degraded"); - expect(errors).toHaveLength(0); - }); - - it("keeps a timed-out check unhealthy and logs it (HC-1 preserved)", async () => { - const { logger, errors } = recordingLogger(); - const registry = new HealthRegistry({ logger }, { checkTimeoutMs: 20 }); - registry.register(checker("hang", () => new Promise(() => undefined))); - const report = await registry.check(); - expect(report.checks.hang?.status).toBe("unhealthy"); - expect(report.checks.hang?.error).toMatch(/registry deadline/); - expect(errors).toHaveLength(1); - expect(errors[0]).toContain("hang"); - }); - - it("stays quiet on the logs when every check is healthy", async () => { - const { logger, warns, errors } = recordingLogger(); - const registry = new HealthRegistry({ logger }); - registry.register(noopChecker("a")); - registry.register(noopChecker("b")); - await registry.check(); - expect(warns).toHaveLength(0); - expect(errors).toHaveLength(0); - }); - - it("opens a span per check tagged with the check name and status", async () => { - const observer = makeRecordingObserver(); - const registry = new HealthRegistry({ observer }); - registry.register(noopChecker("a")); - registry.register(checker("b", () => Promise.reject(new Error("down")))); - await registry.check(); - - expect(observer.runs.map((r) => r.operation)).toEqual(["check", "check"]); - expect(observer.data().check).toBeDefined(); - const statuses = observer.forKey("status").map((o) => o.value); - expect(statuses).toContain("healthy"); - expect(statuses).toContain("unhealthy"); - const checkNames = observer.forKey("check").map((o) => o.value); - expect(checkNames).toContain("a"); - expect(checkNames).toContain("b"); - }); -}); diff --git a/packages/healthcheck/src/index.ts b/packages/healthcheck/src/index.ts deleted file mode 100644 index f45952f..0000000 --- a/packages/healthcheck/src/index.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { - makeObserver, - type Logger, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -/** - * The health of a single component or of the aggregate. `degraded` means functional but - * impaired — serve traffic, but page someone. Ordered worst-last for aggregation. - */ -export type HealthStatus = "healthy" | "degraded" | "unhealthy"; - -/** The outcome of one {@link Checker.check}. */ -export interface CheckResult { - status: HealthStatus; - /** Human-readable context for a healthy or degraded result. */ - detail?: string; - /** The error message when the check failed; present only on `unhealthy`. */ - error?: string; - /** Wall-clock duration of the check, measured with `performance.now()`. */ - durationMs: number; -} - -/** - * Probes one component's health. A package's `ping()` is the natural body of a checker — wrap - * it with {@link checker} rather than implementing this interface by hand. - */ -export interface Checker { - readonly name: string; - check(signal?: AbortSignal): Promise; -} - -/** Options for {@link checker}. */ -export interface CheckerOptions { - /** When set, the check is raced against this deadline and reports `unhealthy` on timeout. */ - timeoutMs?: number; -} - -function errorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} - -/** Whether an error is an abort (a `DOMException`/`Error` named `AbortError`). */ -function isAbortError(err: unknown): boolean { - return err instanceof Error && err.name === "AbortError"; -} - -/** - * Builds a {@link Checker} from a plain async function. The function reports health by what it - * returns: a resolved `void` is `healthy`, a {@link CheckResult} partial overrides the - * defaults, and a thrown error becomes `unhealthy` carrying the message. Duration is always - * measured, and a `timeoutMs` races the function against a deadline. - */ -export function checker( - name: string, - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- a void resolve means "healthy". - fn: (signal?: AbortSignal) => Promise>, - opts: CheckerOptions = {}, -): Checker { - return { - name, - async check(signal?: AbortSignal): Promise { - const start = performance.now(); - const duration = (): number => performance.now() - start; - try { - const partial = await runWithTimeout(fn, signal, opts.timeoutMs); - return { status: "healthy", durationMs: duration(), ...(partial ?? {}) }; - } catch (err) { - // A caller-initiated cancellation is not a health signal — propagate it rather than - // reporting the component `unhealthy`. The `timeoutMs` backstop rejects with a plain - // (non-AbortError) timeout error, so it still surfaces as unhealthy below. - if (signal?.aborted && isAbortError(err)) { - throw err; - } - return { status: "unhealthy", error: errorMessage(err), durationMs: duration() }; - } - }, - }; -} - -async function runWithTimeout( - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- mirrors checker's fn. - fn: (signal?: AbortSignal) => Promise>, - signal?: AbortSignal, - timeoutMs?: number, - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- mirrors fn's return. -): Promise> { - if (timeoutMs === undefined) { - return fn(signal); - } - const timeout = AbortSignal.timeout(timeoutMs); - const merged = signal ? AbortSignal.any([signal, timeout]) : timeout; - return Promise.race([ - fn(merged), - new Promise((_resolve, reject) => { - timeout.addEventListener( - "abort", - () => { - reject(new Error(`health check timed out after ${String(timeoutMs)}ms`)); - }, - { once: true }, - ); - }), - ]); -} - -/** A {@link Checker} that is always healthy. A useful default or placeholder. */ -export function noopChecker(name: string): Checker { - return checker(name, () => Promise.resolve()); -} - -/** The aggregate health of every registered {@link Checker}, keyed by checker name. */ -export interface HealthReport { - status: HealthStatus; - checks: Record; -} - -function aggregate(results: readonly CheckResult[]): HealthStatus { - if (results.some((r) => r.status === "unhealthy")) { - return "unhealthy"; - } - if (results.some((r) => r.status === "degraded")) { - return "degraded"; - } - return "healthy"; -} - -const o11yName = "healthcheck"; - -/** The registry-wide per-check deadline applied when a checker brings no `timeoutMs` of its own. */ -const DEFAULT_CHECK_TIMEOUT_MS = 10_000; - -/** Options for {@link HealthRegistry}. */ -export interface HealthRegistryOptions { - /** - * Registry-wide deadline (ms) applied to *every* check as a backstop, so a checker built without - * its own `timeoutMs` can't hang the whole report forever. A check that outlives it reports - * `unhealthy` with a timeout error. Defaults to {@link DEFAULT_CHECK_TIMEOUT_MS}; set `0` to - * disable (opt back into unbounded checks). - */ - checkTimeoutMs?: number; -} - -/** - * Collects {@link Checker}s and runs them concurrently into a single {@link HealthReport}. - * The aggregate is `unhealthy` if any check is, else `degraded` if any is, else `healthy`; - * an empty registry is `healthy`. Registration is last-wins: re-registering a name replaces - * the prior checker. - */ -export class HealthRegistry { - readonly #checkers = new Map(); - readonly #observer: Observer; - readonly #logger: Logger; - readonly #checkTimeoutMs: number | undefined; - - constructor(deps: ObservabilityDeps = {}, options: HealthRegistryOptions = {}) { - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#logger = this.#observer.logger(); - const timeout = options.checkTimeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS; - this.#checkTimeoutMs = timeout > 0 ? timeout : undefined; - } - - /** Adds a checker, replacing any existing checker of the same name. */ - register(checker: Checker): void { - this.#checkers.set(checker.name, checker); - } - - /** The registered checkers, in registration order. */ - checkers(): readonly Checker[] { - return [...this.#checkers.values()]; - } - - /** Runs every checker concurrently and aggregates the results. */ - async check(signal?: AbortSignal): Promise { - const entries = [...this.#checkers.values()]; - this.#logger.debug("running health checks"); - const results = await Promise.all( - entries.map(async (c) => [c.name, await this.#runChecker(c, signal)] as const), - ); - const checks: Record = {}; - for (const [name, result] of results) { - checks[name] = result; - } - return { status: aggregate(results.map(([, result]) => result)), checks }; - } - - /** - * Runs one checker inside its own span, tagging the span/log with the check name and outcome - * and surfacing a failing component in the logs: `unhealthy` logs at error, `degraded` at - * warn, healthy stays quiet. An unhealthy result is a value rather than a throw, so it is - * logged explicitly instead of routed through `op.error`. - */ - #runChecker(c: Checker, signal?: AbortSignal): Promise { - return this.#observer.run("check", async (op) => { - op.set("check", c.name); - const result = await this.#raceDeadline(c, signal); - op.set("status", result.status); - if (result.status === "unhealthy") { - op.logger().error(`health check '${c.name}' is unhealthy`, result.error); - } else if (result.status === "degraded") { - op.logger().warn(`health check '${c.name}' is degraded`, { - detail: result.detail, - }); - } - return result; - }); - } - - /** - * Runs one checker under the registry deadline. A checker with its own (shorter) `timeoutMs` - * still resolves first; this only backstops one that would otherwise hang the whole report. - */ - async #raceDeadline(c: Checker, signal?: AbortSignal): Promise { - if (this.#checkTimeoutMs === undefined) { - return c.check(signal); - } - const start = performance.now(); - let timer: ReturnType | undefined; - const deadline = new Promise((resolve) => { - timer = setTimeout(() => { - resolve({ - status: "unhealthy", - error: `health check '${c.name}' exceeded the registry deadline of ${String(this.#checkTimeoutMs)}ms`, - durationMs: performance.now() - start, - }); - }, this.#checkTimeoutMs); - }); - try { - return await Promise.race([c.check(signal), deadline]); - } finally { - if (timer !== undefined) { - clearTimeout(timer); - } - } - } -} diff --git a/packages/healthcheck/tsconfig.json b/packages/healthcheck/tsconfig.json deleted file mode 100644 index bdb9327..0000000 --- a/packages/healthcheck/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022"], - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/healthcheck/tsup.config.ts b/packages/healthcheck/tsup.config.ts deleted file mode 100644 index 8bf2394..0000000 --- a/packages/healthcheck/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { index: "src/index.ts" }, - platform: "node", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: true, -}); diff --git a/packages/idempotency/README.md b/packages/idempotency/README.md deleted file mode 100644 index 1da5eb9..0000000 --- a/packages/idempotency/README.md +++ /dev/null @@ -1,229 +0,0 @@ -# @primandproper/idempotency - -Runs work at most once per client-supplied key — the TypeScript port of platform-go's -`idempotency`. - -It exists for the case where a client sends a request, never sees the response, retries, and the -work in between spent real money. Without a key the server cannot tell that second request apart -from a deliberate second purchase, so it charges the card twice. - -## The client mints the key - -This is the part most often read backwards. **The server never issues a key.** The client -generates one before its first attempt and reuses that same value on every retry of the same -logical operation: - -```ts -import { idempotentFetch } from "@primandproper/idempotency"; - -const send = idempotentFetch(); // mints ONE key, OUTSIDE the retry loop - -await policy.run(() => send("/charges", { method: "POST", body })); -``` - -The wrapper _is_ the key — that shape is deliberate. A key minted inside the retry loop is a new -key per attempt, which looks like protection and provides none; nothing on the server can detect -the mistake, because a retry and a deliberate duplicate are byte-identical. Making the key belong -to a wrapper you construct means the mistake is at least visible: the construction is what moved. - -There is no round trip to acquire a key, because a request that times out never returns anything. - -## What it guarantees - -At-most-once **effect**, not exactly-once. The gap is worth naming: - -- a recorded result is **replayed** instead of re-run; -- work that started and has not reported back is **refused**, because "did it happen?" is - unanswerable and running it again is the worse guess; -- a key reused for a _different_ request is reported as a **mismatch** rather than answered with - the earlier result. - -What it cannot promise: work that has its effect and _then_ fails. The charge landed, the error -came back, nothing was recorded, and the retry charges again. Recording failures instead would be -worse — a transient error would be pinned for the whole TTL and the client could never succeed. -That is what `recordable` is for: a caller can say "that failure was ours, not theirs". - -## Server side - -```ts -import { provideCache } from "@primandproper/cache"; -import { provideDistributedLock } from "@primandproper/distributedlock"; -import { - fingerprintRequest, - parseIdempotencyKey, - provideIdempotencyManager, - IDEMPOTENCY_KEY_HEADER, - type IdempotencyRecord, -} from "@primandproper/idempotency"; - -interface Receipt { - chargeId: string; - charged: boolean; -} - -const manager = provideIdempotencyManager( - { inFlightTtlMs: 120_000, ttlMs: 86_400_000 }, - { - store: provideCache>({ - provider: "redis", - redis: { url }, - }), - lock: provideDistributedLock({ provider: "redis", redis: { url } }), - recordable: (receipt) => receipt.charged, - logger, - }, -); - -const key = parseIdempotencyKey(req.headers.get(IDEMPOTENCY_KEY_HEADER) ?? ""); -const fingerprint = await fingerprintRequest({ - method: req.method, - url: req.url, - principal: user.id, - body, -}); - -const result = await manager.run(key, fingerprint, () => charge(body)); -switch (result.status) { - case "executed": - case "replayed": - return json(result.value); - case "in-flight": - return status(409); // retry later; the answer is not knowable yet - case "fingerprint-mismatch": - return status(422); // a client bug, not a retry -} -``` - -The four outcomes are **returned, not thrown**: they are expected control flow, and the -discriminated union fits this repo's optional-over-sentinels stance better than Go's error -sentinels. Thrown `PlatformError`s (`idempotency/*` codes) are reserved for genuine failures — an -unusable key, an empty fingerprint, an unreachable record store. - -## The claim protocol - -1. read the record — replay, refuse, or continue; -2. lock → **re-read** → write an in-flight claim → unlock; -3. run the work **outside** the lock; -4. record the result, or release the claim. - -The re-read inside the lock is what makes it correct: two callers that both missed the pre-lock -read would otherwise both claim, and the second would overwrite the first. - -The obvious alternative — hold the lock for the whole execution and let the lock itself mean "in -flight" — is wrong for reasons that survive the port from Go: - -- **A held lock is a held resource.** The postgres lock provider runs inside a transaction; - holding it for the duration of the work means an open transaction per in-flight request — pool - exhaustion, blocked vacuums, replication lag. -- **Lock keys can collide.** Postgres advisory locks fold a key into an int64, so unrelated keys - can contend. Under a held lock a collision answers a legitimate request with a refusal; under a - short one it costs a sub-millisecond wait (`lockWaitMs`). -- **Lock TTLs are shorter than real work.** Any work slower than the lease loses mutual exclusion - _while still running_ — precisely the failure this package exists to prevent. -- **A lock leaves no evidence.** Kill a process mid-execution and the lock evaporates, so the - retry runs the work again. A _record_ with its own TTL survives, and the retry is correctly - refused until it expires. - -Every execution therefore writes twice — a claim, then an outcome — and the claim carries a claim -id so that only its owner may complete or release it. That is what stops an execution which -outlived its claim from overwriting whoever re-claimed the key. - -`withLock(lock, key, fn)` is exported for callers that want the acquire/run/release shape without -the manager. `DistributedLock.acquire` reports contention immediately rather than blocking, so it -retries for `waitMs` before reporting `{ acquired: false }`. - -## Choosing TTLs - -`inFlightTtlMs` is a **deadline for the work**, not a tuning knob. Set it above the worst case, -not the average — every execution slower than it can produce a duplicate. Two minutes suits a -request-shaped workload. It is also how long a client is refused after a process dies -mid-execution; with the outcome unknown, refusing is the conservative answer. - -`ttlMs` is how long a client may usefully retry. A day is the common answer and matches what -payment providers publish. Endpoints that disagree do not need a manager each: `run` takes a -per-call `ttlMs`. There is deliberately no per-call `inFlightTtlMs` — it bounds how long a dead -process blocks a retry, which is a property of the deployment rather than of the call. - -## Store failure policy - -The two answers fail in opposite directions, so the choice belongs to the caller. -`fail-closed` (the default) refuses the request when a record read fails: a brief outage becomes -downtime rather than duplicate charges, which is the right answer wherever the guarded work costs -money. `fail-open` treats the failed read as a miss and runs the work anyway, trading the -guarantee for availability. - -**The policy governs reads only**, matching platform-go. A read can fail open because "no record" -is a coherent — if unprotected — answer to carry on from. A claim that could not be _written_ -leaves the completion nothing to prove ownership against, so a failed claim write, or an -unreachable locker, refuses the request under either policy. - -The practical consequence is worth stating plainly: a store that is _wholly_ unreachable refuses -the request even under `fail-open`, because the claim write cannot land either. What `fail-open` -buys is tolerance of a failing **read** path — and in that case the completion write is also -skipped (the claim cannot be confirmed as ours), so the execution counts against -`idempotency.claims.lost` and a later retry will re-run the work. - -## The locker matters - -The noop locker acquires unconditionally. With it, replay still works — which covers the ordinary -timeout-then-retry case — but two genuinely concurrent requests can both claim and both execute. -The `lock` dep is required and has no default so that nobody arrives there by accident. - -## Fingerprints - -A fingerprint identifies _what_ the operation was, so a key reused for a different request can be -detected. `fingerprintRequest` covers method, path, **sorted** query, principal, and body; parts -are length-prefixed so they cannot run together (`/a` + `bc` must not hash like `/ab` + `c`). - -`canonicalJson` is the canonicalisation: object keys sorted recursively, array order preserved, -`toJSON` honoured, `NaN`/`Infinity` as `null`. It exists because a false mismatch is a _rejected -legitimate retry_ — property order and re-serialisation must not change the answer. - -Keys and fingerprints are branded string types. `run` takes one of each, adjacently, and as bare -strings a transposed pair would type-check and silently disable mismatch detection — a security -control failing open with no signal. Convert at the wire boundary with `parseIdempotencyKey` -(which also validates: printable, space-free ASCII, length-capped) and `asFingerprint`. - -## Watching it - -| Instrument | Watch it for | -| ----------------------------- | ---------------------------------------------------------------- | -| `idempotency.claims.lost` | **The alert.** Work outran `inFlightTtlMs`; the claim was taken | -| `idempotency.record.failures` | The effect happened, the record did not — a retry will re-run it | -| `idempotency.requests` | By `outcome`; the four sum to the request total | -| `idempotency.store.errors` | Store health | -| `idempotency.stale.records` | Records ignored for another version; one spike per shape change | - -A steady stream of `in_flight` without matching `executed` usually means work is dying -mid-execution. `mismatch` is always a client bug. End-to-end latency comes from observability's -`operation.duration{operation="run"}`, so there is no second histogram here saying the same thing. - -## Record versioning - -Every record is stamped with `RECORD_VERSION`, and a record written by another version is -**ignored rather than misread** — it reads as a miss and the work re-runs. With a day-long TTL, -treating an unreadable record as an error would turn one bad deploy into a day of failures. Bump -`RECORD_VERSION` when the stored shape changes. - -Keep `T` JSON-safe: the redis cache provider serialises with JSON, so `Date`/`Map`/`Set` do not -survive the round trip as themselves. - -## The isomorphic split - -The split is **asymmetric**, which is the one place this package departs from the usual -"identical factory on both sides" shape: - -- `index.browser.ts` — key minting, fingerprints, and the `fetch` wrapper. -- `index.node.ts` — the same client half, plus `provideIdempotencyManager`. - -A browser has no record store and no distributed lock, so a manager there could only pretend, and -a noop stand-in would be the worst possible shape: idempotency that looks wired up and guarantees -nothing. Importing `provideIdempotencyManager` from browser-resolved code is therefore a type -error, which is the intended feedback. The client half ships to the browser because that is where -the retrying client lives — which is where "mint outside the retry loop" actually gets broken. - -## Not ported - -The transport adapters (`idempotency/http`, `idempotency/grpc`) — server middleware, response -capture and replay, and the gRPC interceptors. This package knows nothing about HTTP beyond the -header name and `fingerprintRequest`; wiring it into a router is currently the caller's job. diff --git a/packages/idempotency/package.json b/packages/idempotency/package.json deleted file mode 100644 index e42890c..0000000 --- a/packages/idempotency/package.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "@primandproper/idempotency", - "version": "0.1.0", - "type": "module", - "description": "Isomorphic idempotency: client key minting plus a server-side manager that runs work at most once per key.", - "license": "AGPL-3.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/primandproper/platform-ts.git", - "directory": "packages/idempotency" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "browser": { - "types": "./dist/index.browser.d.ts", - "default": "./dist/index.browser.js" - }, - "node": { - "types": "./dist/index.node.d.ts", - "default": "./dist/index.node.js" - }, - "default": { - "types": "./dist/index.node.d.ts", - "default": "./dist/index.node.js" - } - } - }, - "types": "./dist/index.node.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "eslint .", - "clean": "rm -rf dist .turbo" - }, - "dependencies": { - "@primandproper/cache": "workspace:*", - "@primandproper/cryptography": "workspace:*", - "@primandproper/distributedlock": "workspace:*", - "@primandproper/errors": "workspace:*", - "@primandproper/identifiers": "workspace:*", - "@primandproper/observability": "workspace:*", - "zod": "3.25.76" - } -} diff --git a/packages/idempotency/src/client.test.ts b/packages/idempotency/src/client.test.ts deleted file mode 100644 index b48ffa7..0000000 --- a/packages/idempotency/src/client.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { idempotentFetch } from "./client.js"; -import { IDEMPOTENCY_KEY_HEADER, parseIdempotencyKey } from "./key.js"; - -/** Captures what the wrapper would have sent, without a network. */ -function recordingFetch(): { - fetch: typeof fetch; - calls: { input: RequestInfo | URL; init: RequestInit | undefined }[]; -} { - const calls: { input: RequestInfo | URL; init: RequestInit | undefined }[] = []; - return { - calls, - fetch: (input: RequestInfo | URL, init?: RequestInit): Promise => { - calls.push({ input, init }); - return Promise.resolve(new Response(null, { status: 204 })); - }, - }; -} - -/** The key the wrapper actually put on the wire for call `index`. */ -function sentKey(init: RequestInit | undefined): string | null { - return new Headers(init?.headers).get(IDEMPOTENCY_KEY_HEADER); -} - -describe("idempotentFetch", () => { - it("stamps the key it minted", async () => { - const spy = recordingFetch(); - const send = idempotentFetch({ fetch: spy.fetch }); - - await send("/charges", { method: "POST" }); - - expect(sentKey(spy.calls[0]?.init)).toBe(send.key); - }); - - it("sends the same key on every attempt — the whole point of binding it to the wrapper", async () => { - const spy = recordingFetch(); - const send = idempotentFetch({ fetch: spy.fetch }); - - // Standing in for a retry loop: the wrapper is built once, outside it. - await send("/charges", { method: "POST", body: "{}" }); - await send("/charges", { method: "POST", body: "{}" }); - - expect(sentKey(spy.calls[0]?.init)).toBe(sentKey(spy.calls[1]?.init)); - }); - - it("leaves safe methods alone", async () => { - const spy = recordingFetch(); - const send = idempotentFetch({ fetch: spy.fetch }); - - await send("/charges"); - await send("/charges", { method: "GET" }); - - expect(sentKey(spy.calls[0]?.init)).toBeNull(); - expect(sentKey(spy.calls[1]?.init)).toBeNull(); - }); - - it("never overrides a key the caller set itself", async () => { - const spy = recordingFetch(); - const send = idempotentFetch({ fetch: spy.fetch }); - - await send("/charges", { - method: "POST", - headers: { [IDEMPOTENCY_KEY_HEADER]: "caller-owned" }, - }); - - expect(sentKey(spy.calls[0]?.init)).toBe("caller-owned"); - }); - - it("reuses a supplied key instead of minting one", () => { - const key = parseIdempotencyKey("resumed-operation"); - expect(idempotentFetch({ key }).key).toBe(key); - }); - - it("does not mutate the init the caller passed", async () => { - const spy = recordingFetch(); - const send = idempotentFetch({ fetch: spy.fetch }); - // The shape a retry loop produces: one init object reused for every attempt. - const init: RequestInit = { - method: "POST", - headers: { "content-type": "text/plain" }, - }; - - await send("/charges", init); - - expect(new Headers(init.headers).has(IDEMPOTENCY_KEY_HEADER)).toBe(false); - expect(sentKey(spy.calls[0]?.init)).toBe(send.key); - expect(new Headers(spy.calls[0]?.init?.headers).get("content-type")).toBe( - "text/plain", - ); - }); - - it("keeps a Request's own headers when no init headers are given", async () => { - const spy = recordingFetch(); - const send = idempotentFetch({ fetch: spy.fetch }); - - await send( - new Request("https://api.example.com/charges", { - method: "POST", - headers: { "x-trace": "abc" }, - }), - ); - - expect(new Headers(spy.calls[0]?.init?.headers).get("x-trace")).toBe("abc"); - expect(sentKey(spy.calls[0]?.init)).toBe(send.key); - }); - - it("honours a Request's own already-set key", async () => { - const spy = recordingFetch(); - const send = idempotentFetch({ fetch: spy.fetch }); - - await send( - new Request("https://api.example.com/charges", { - method: "POST", - headers: { [IDEMPOTENCY_KEY_HEADER]: "caller-owned" }, - }), - ); - - expect(spy.calls[0]?.init).toBeUndefined(); - }); - - it("takes a custom header name and method set", async () => { - const spy = recordingFetch(); - const send = idempotentFetch({ - fetch: spy.fetch, - headerName: "X-Idempotency", - methods: ["GET"], - }); - - await send("/charges"); - await send("/charges", { method: "POST" }); - - expect(new Headers(spy.calls[0]?.init?.headers).get("X-Idempotency")).toBe(send.key); - expect(spy.calls[1]?.init?.headers).toBeUndefined(); - }); -}); diff --git a/packages/idempotency/src/client.ts b/packages/idempotency/src/client.ts deleted file mode 100644 index 9b12e22..0000000 --- a/packages/idempotency/src/client.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { - IDEMPOTENCY_KEY_HEADER, - newIdempotencyKey, - type IdempotencyKey, - type KeyDeps, -} from "./key.js"; - -/** - * The methods that participate by default. Safe methods are excluded even when a key is - * present: they have no effect to deduplicate, and recording them would spend the store on - * nothing. - */ -export const IDEMPOTENT_METHODS: readonly string[] = ["POST", "PUT", "PATCH", "DELETE"]; - -/** Options for {@link idempotentFetch}. */ -export interface IdempotentFetchOptions extends KeyDeps { - /** The `fetch` to delegate to. Defaults to the global. */ - fetch?: typeof fetch; - /** - * Reuse an existing key instead of minting one — for a caller that already has the key - * (rehydrating a resumed operation, say, or one it logged before the first attempt). - */ - key?: IdempotencyKey; - /** Overrides the header the key is sent in. */ - headerName?: string; - /** Overrides which methods carry the key. */ - methods?: readonly string[]; -} - -/** A `fetch` that stamps one operation's idempotency key on every request it sends. */ -export interface IdempotentFetch { - (input: RequestInfo | URL, init?: RequestInit): Promise; - /** The key this wrapper stamps. Log it: it is what a support request is traced by. */ - readonly key: IdempotencyKey; - /** The header the key is sent in. */ - readonly headerName: string; -} - -/** Guards the `Request` global, which an exotic host may not define even where `fetch` exists. */ -function isRequest(input: RequestInfo | URL): input is Request { - return typeof Request !== "undefined" && input instanceof Request; -} - -/** Reads the method an about-to-be-sent request will use, defaulting as `fetch` does. */ -function methodOf(input: RequestInfo | URL, init?: RequestInit): string { - if (init?.method !== undefined) { - return init.method.toUpperCase(); - } - return isRequest(input) ? input.method.toUpperCase() : "GET"; -} - -/** - * The headers the request would be sent with, unchanged. `fetch(request, init)` takes headers - * from `init` *instead of* the request's when `init.headers` is present rather than merging - * them, so stamping onto a merge of the two would resurrect headers the caller meant to drop. - */ -function effectiveHeaders(input: RequestInfo | URL, init?: RequestInit): Headers { - if (init?.headers !== undefined) { - return new Headers(init.headers); - } - return new Headers(isRequest(input) ? input.headers : undefined); -} - -/** - * Binds one freshly minted idempotency key to a `fetch` wrapper, so every request it sends - * carries that same key. - * - * **The wrapper is the key**, and that is the whole point of this shape. Build it once per - * logical operation, outside the retry loop: - * - * ```ts - * const send = idempotentFetch(); // mints once, OUTSIDE the loop - * await policy.run(() => send("/charges", { method: "POST", body })); - * ``` - * - * Building it *inside* the loop mints a key per attempt — which looks like protection and - * provides none, since a retry and a deliberate duplicate are byte-identical to the server. The - * mistake is at least visible here, because the construction is what moved. - * - * There is deliberately no "mint a key per call" mode. A wrapper cannot tell a retry from a - * second, intentional request, so inventing a key per call would offer nothing, and deriving one - * from the request's content would fail the other way — deciding two intentional identical - * charges are the same one and silently dropping the second. Only the caller knows where a - * logical operation begins. - * - * An already-set header always wins, and safe methods are left alone. - */ -export function idempotentFetch(options: IdempotentFetchOptions = {}): IdempotentFetch { - const headerName = options.headerName ?? IDEMPOTENCY_KEY_HEADER; - const methods = options.methods ?? IDEMPOTENT_METHODS; - const key = - options.key ?? - newIdempotencyKey( - options.generate !== undefined ? { generate: options.generate } : {}, - ); - const delegate = - options.fetch ?? ((...args: Parameters) => fetch(...args)); - - const send = (input: RequestInfo | URL, init?: RequestInit): Promise => { - const headers = effectiveHeaders(input, init); - // An already-set key means the caller is managing keys itself; never override it. - if (!methods.includes(methodOf(input, init)) || headers.has(headerName)) { - return delegate(input, init); - } - - // The header goes on a copy of `init` rather than being mutated in place: a caller that - // builds one `init` outside its retry loop and passes it to every attempt must not have that - // object rewritten underneath it. - headers.set(headerName, key); - return delegate(input, { ...init, headers }); - }; - - return Object.assign(send, { key, headerName }); -} diff --git a/packages/idempotency/src/config.test.ts b/packages/idempotency/src/config.test.ts deleted file mode 100644 index 4bc12fa..0000000 --- a/packages/idempotency/src/config.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { IdempotencyConfigSchema } from "./config.js"; - -describe("IdempotencyConfigSchema", () => { - it("defaults to a day of retention, a two-minute claim, and failing closed", () => { - expect(IdempotencyConfigSchema.parse({})).toEqual({ - keyPrefix: "idempotency:", - ttlMs: 86_400_000, - inFlightTtlMs: 120_000, - maxKeyLength: 255, - storeFailurePolicy: "fail-closed", - lockTtlMs: 5_000, - lockWaitMs: 2_000, - lockPollMs: 25, - }); - }); - - it("honours an empty key prefix rather than treating it as unset", () => { - // Opting out of namespacing is a legitimate choice — e.g. a store used for nothing else. - expect(IdempotencyConfigSchema.parse({ keyPrefix: "" }).keyPrefix).toBe(""); - }); - - it("rejects a non-positive TTL", () => { - expect(() => IdempotencyConfigSchema.parse({ ttlMs: 0 })).toThrow(); - expect(() => IdempotencyConfigSchema.parse({ inFlightTtlMs: -1 })).toThrow(); - }); - - it("allows a zero key-length cap (disabling the check) and a zero lock wait", () => { - expect(IdempotencyConfigSchema.parse({ maxKeyLength: 0 }).maxKeyLength).toBe(0); - expect(IdempotencyConfigSchema.parse({ lockWaitMs: 0 }).lockWaitMs).toBe(0); - }); - - it("rejects an unknown store failure policy", () => { - expect(() => - IdempotencyConfigSchema.parse({ storeFailurePolicy: "maybe" }), - ).toThrow(); - }); -}); diff --git a/packages/idempotency/src/config.ts b/packages/idempotency/src/config.ts deleted file mode 100644 index 9d53ca9..0000000 --- a/packages/idempotency/src/config.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { z } from "zod"; - -import { DEFAULT_MAX_KEY_LENGTH } from "./key.js"; - -/** One day, in milliseconds — how long a completed record stays replayable by default. */ -const DEFAULT_TTL_MS = 86_400_000; - -/** Two minutes, in milliseconds — the default deadline for the work a claim guards. */ -const DEFAULT_IN_FLIGHT_TTL_MS = 120_000; - -/** - * Idempotency config. Replaces the Go platform's `env:`-tagged struct + ozzo validation. - * - * The record store and the locker are **not** configured here: both are runtime values built by - * their own packages' factories and passed through deps, which is how the rest of this repo - * composes (no DI container). - */ -export const IdempotencyConfigSchema = z.object({ - /** - * Namespaces store and lock keys, so a client's key cannot collide with an unrelated entry in - * a cache or locker shared with something else. An empty prefix is honoured — opting out is a - * legitimate choice. - */ - keyPrefix: z.string().default("idempotency:"), - /** - * How long a completed record stays replayable, in milliseconds. This is how long a client may - * usefully retry; a day is the common answer and matches what payment providers publish. - * Longer costs storage, shorter means a late retry re-executes. - */ - ttlMs: z.number().int().positive().default(DEFAULT_TTL_MS), - /** - * How long a claim survives without being completed, in milliseconds. - * - * A deadline for the guarded work, not a tuning knob: set it above the worst case, not the - * average. Every execution slower than this loses its claim *while still running*, which is - * the one remaining path to a duplicate effect — watch `idempotency.claims.lost`. It is also - * how long a client is refused after a process dies mid-execution, which is the best available - * answer when the outcome is unknown. - */ - inFlightTtlMs: z.number().int().positive().default(DEFAULT_IN_FLIGHT_TTL_MS), - /** The longest client key accepted. `0` disables the length check. */ - maxKeyLength: z.number().int().nonnegative().default(DEFAULT_MAX_KEY_LENGTH), - /** - * What happens when a record **read** fails. The most consequential setting here, because the - * two answers fail in opposite directions. - * - * `fail-closed` (the default) refuses the request: a brief outage becomes downtime rather than - * duplicate charges, and it is the right answer wherever the guarded work costs money. - * `fail-open` treats the failed read as a miss and runs the work anyway, trading the guarantee - * for availability — appropriate only where a duplicate effect is cheaper than a rejection. - * - * It governs reads only, matching platform-go. A claim that could not be *written* leaves the - * completion nothing to prove ownership against, so a failed claim write (or an unreachable - * locker) refuses the request under either policy. In practice that means a store which is - * wholly unreachable refuses the request even under `fail-open`; what `fail-open` buys is - * tolerance of a failing read path. - */ - storeFailurePolicy: z.enum(["fail-closed", "fail-open"]).default("fail-closed"), - /** - * Lease held while claiming, in milliseconds. Short by design: the lock covers a re-read and a - * write, never the work itself. - */ - lockTtlMs: z.number().int().positive().default(5_000), - /** - * How long to keep retrying a contended claim lock before giving up and answering `in-flight`, - * in milliseconds. `0` means a single attempt. - * - * A wait exists because `DistributedLock.acquire` reports contention immediately rather than - * blocking, and because lock keys can collide: the postgres provider folds a key into an - * int64, so an unrelated key can contend. Under a short wait that collision costs a - * sub-millisecond pause; without one it would answer a legitimate request with a refusal. - */ - lockWaitMs: z.number().int().nonnegative().default(2_000), - /** How long to wait between attempts on a contended claim lock, in milliseconds. */ - lockPollMs: z.number().int().positive().default(25), -}); - -export type IdempotencyConfig = z.infer; -export type IdempotencyConfigInput = z.input; - -/** The store-failure policies, spelled out for callers switching on them. */ -export type StoreFailurePolicy = IdempotencyConfig["storeFailurePolicy"]; diff --git a/packages/idempotency/src/fingerprint.test.ts b/packages/idempotency/src/fingerprint.test.ts deleted file mode 100644 index c6edde6..0000000 --- a/packages/idempotency/src/fingerprint.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - canonicalJson, - fingerprintJson, - fingerprintOf, - fingerprintRequest, -} from "./fingerprint.js"; - -describe("canonicalJson", () => { - it("sorts object keys recursively, so property order is not a false mismatch", () => { - expect(canonicalJson({ b: 1, a: { d: 2, c: 3 } })).toBe( - canonicalJson({ a: { c: 3, d: 2 }, b: 1 }), - ); - }); - - it("keeps array order, because order is meaning in an array", () => { - expect(canonicalJson([1, 2])).not.toBe(canonicalJson([2, 1])); - }); - - it("normalises numbers the way JSON does, so a re-serialised payload still matches", () => { - expect(canonicalJson({ n: 1.0 })).toBe(canonicalJson({ n: 1 })); - expect(canonicalJson({ n: Number.NaN })).toBe('{"n":null}'); - }); - - it("honours toJSON, so a Date canonicalises to its ISO string", () => { - expect(canonicalJson({ at: new Date(0) })).toBe('{"at":"1970-01-01T00:00:00.000Z"}'); - }); - - it("drops undefined from objects and nulls it in arrays, as JSON.stringify does", () => { - expect(canonicalJson({ a: undefined, b: 1 })).toBe('{"b":1}'); - expect(canonicalJson([undefined])).toBe("[null]"); - }); - - it("renders a bare undefined as null rather than returning undefined", () => { - expect(canonicalJson(undefined)).toBe("null"); - }); -}); - -describe("fingerprintOf", () => { - it("is stable for the same parts", async () => { - await expect(fingerprintOf(["a", "b"])).resolves.toBe( - await fingerprintOf(["a", "b"]), - ); - }); - - it("length-prefixes parts, so a shifted boundary is a different fingerprint", async () => { - // Without framing, "a" + "bc" and "ab" + "c" would hash identically — and one user's - // recorded response could be replayed to another. - expect(await fingerprintOf(["a", "bc"])).not.toBe(await fingerprintOf(["ab", "c"])); - }); - - it("accepts raw bytes alongside strings", async () => { - const digest = await fingerprintOf(["POST", new Uint8Array([1, 2, 3])]); - expect(digest).toMatch(/^[0-9a-f]{64}$/); - }); -}); - -describe("fingerprintJson", () => { - it("treats reordered keys as the same request", async () => { - expect(await fingerprintJson({ a: 1, b: 2 })).toBe( - await fingerprintJson({ b: 2, a: 1 }), - ); - }); - - it("treats a different value as a different request", async () => { - expect(await fingerprintJson({ amount: 100 })).not.toBe( - await fingerprintJson({ amount: 101 }), - ); - }); -}); - -describe("fingerprintRequest", () => { - const base = { method: "POST", url: "/charges", principal: "user-1", body: "{}" }; - - it("is stable across attempts of the same request", async () => { - expect(await fingerprintRequest(base)).toBe(await fingerprintRequest(base)); - }); - - it("sorts the query, so parameter order is not reported as key reuse", async () => { - expect(await fingerprintRequest({ ...base, url: "/charges?a=1&b=2" })).toBe( - await fingerprintRequest({ ...base, url: "/charges?b=2&a=1" }), - ); - }); - - it("commits to the method, path, query, principal, and body", async () => { - const original = await fingerprintRequest(base); - expect(await fingerprintRequest({ ...base, method: "PUT" })).not.toBe(original); - expect(await fingerprintRequest({ ...base, url: "/refunds" })).not.toBe(original); - expect(await fingerprintRequest({ ...base, url: "/charges?a=1" })).not.toBe(original); - expect(await fingerprintRequest({ ...base, principal: "user-2" })).not.toBe(original); - expect(await fingerprintRequest({ ...base, body: '{"a":1}' })).not.toBe(original); - }); - - it("ignores the origin, so the same request through two hosts matches", async () => { - expect( - await fingerprintRequest({ ...base, url: "https://api.example.com/charges" }), - ).toBe(await fingerprintRequest(base)); - }); - - it("case-folds the method", async () => { - expect(await fingerprintRequest({ ...base, method: "post" })).toBe( - await fingerprintRequest(base), - ); - }); -}); diff --git a/packages/idempotency/src/fingerprint.ts b/packages/idempotency/src/fingerprint.ts deleted file mode 100644 index 78726d4..0000000 --- a/packages/idempotency/src/fingerprint.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { provideHashing, type Hasher } from "@primandproper/cryptography"; - -import { asFingerprint, type Fingerprint } from "./key.js"; - -/** Injectable digest, so a caller can pin the algorithm or a test can pin the output. */ -export interface FingerprintDeps { - /** Defaults to SHA-256 over WebCrypto, identical on Node and in the browser. */ - hasher?: Hasher; -} - -const encoder = new TextEncoder(); - -const defaultHasher = provideHashing({ algorithm: "SHA-256" }); - -/** Renders a digest as lowercase hex — the wire form a fingerprint travels and is stored in. */ -function toHex(bytes: Uint8Array): string { - let hex = ""; - for (const byte of bytes) { - hex += byte.toString(16).padStart(2, "0"); - } - return hex; -} - -/** - * Hashes an ordered list of parts into a fingerprint. - * - * Each part is **length-prefixed** so that parts cannot run together: without it a path of `/a` - * with principal `bc` and a path of `/ab` with principal `c` would hash identically, and one - * user's recorded response could be replayed to another. - */ -export async function fingerprintOf( - parts: readonly (string | Uint8Array)[], - deps: FingerprintDeps = {}, -): Promise { - const hasher = deps.hasher ?? defaultHasher; - const framed: Uint8Array[] = []; - let length = 0; - for (const part of parts) { - const bytes = typeof part === "string" ? encoder.encode(part) : part; - const prefix = encoder.encode(`${String(bytes.byteLength)}:`); - framed.push(prefix, bytes); - length += prefix.byteLength + bytes.byteLength; - } - - const buffer = new Uint8Array(length); - let offset = 0; - for (const chunk of framed) { - buffer.set(chunk, offset); - offset += chunk.byteLength; - } - - return asFingerprint(toHex(await hasher.hash(buffer))); -} - -/** - * Serialises a value to JSON with object keys sorted, recursively — the canonicalisation a - * fingerprint over structured data needs. - * - * Two things would otherwise produce false mismatches, and a false mismatch is a *rejected - * legitimate retry*: property order (`{a,b}` and `{b,a}` are the same request), and a client - * that re-serialises its payload between attempts. Sorting fixes the first. The second is why - * fingerprinting structured data is preferred over hashing raw bytes wherever the caller has - * the parsed value to hand. - * - * What it commits to: keys sort by UTF-16 code unit; arrays keep their order (order is meaning - * in an array); `undefined`, functions, and symbols drop out of objects and become `null` in - * arrays, exactly as `JSON.stringify` does; `toJSON` is honoured, so a `Date` canonicalises to - * its ISO string; `NaN`/`Infinity` become `null`. Number formatting is deterministic — the - * ECMAScript spec pins `Number`-to-`String` — so `1.0` and `1` canonicalise identically, which - * is the answer that treats a re-serialised payload as the same request. - */ -export function canonicalJson(value: unknown): string { - // Handled up front because `JSON.stringify` returns `undefined` (not a string) for these, - // which its lib type does not admit — and a fingerprint must always be a string. - if (value === undefined || typeof value === "function" || typeof value === "symbol") { - return "null"; - } - - return JSON.stringify(value, (_key, entry: unknown) => { - if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { - return entry; - } - // The replacer runs after `toJSON`, so anything reaching here is a plain-ish object whose - // own enumerable keys are what get serialised — reordering them here reorders the output. - return Object.fromEntries( - Object.entries(entry as Record).sort(([a], [b]) => - a < b ? -1 : a > b ? 1 : 0, - ), - ); - }); -} - -/** Fingerprints a structured value through {@link canonicalJson}. */ -export function fingerprintJson( - value: unknown, - deps: FingerprintDeps = {}, -): Promise { - return fingerprintOf([canonicalJson(value)], deps); -} - -/** The parts of an HTTP request a fingerprint commits to. */ -export interface RequestFingerprintInput { - method: string; - /** Absolute or relative; only the path and query participate. */ - url: string | URL; - /** - * Who is making the request. Include it: a fingerprint over the request alone would let the - * same payload sent by two different users share one record, handing the second caller the - * first's response. - */ - principal?: string; - /** The raw body, or a structured value to canonicalise via {@link canonicalJson}. */ - body?: string | Uint8Array; -} - -/** - * Fingerprints an HTTP request over method, path, sorted query, principal, and body. - * - * It covers more than the body on purpose — a key committed only to a body would let the same - * payload posted to two endpoints share one record. The query is sorted rather than taken - * verbatim, because `?a=1&b=2` and `?b=2&a=1` are the same request and reporting an ordinary - * retry as key reuse is the expensive direction to be wrong in. - */ -export function fingerprintRequest( - input: RequestFingerprintInput, - deps: FingerprintDeps = {}, -): Promise { - // A base is supplied so a relative URL parses; only path and query are read back out, so the - // placeholder origin never reaches the digest. - const url = - typeof input.url === "string" - ? new URL(input.url, "http://fingerprint.invalid") - : input.url; - const query = new URLSearchParams(url.search); - query.sort(); - - return fingerprintOf( - [ - input.method.toUpperCase(), - url.pathname, - query.toString(), - input.principal ?? "", - input.body ?? "", - ], - deps, - ); -} diff --git a/packages/idempotency/src/index.browser.ts b/packages/idempotency/src/index.browser.ts deleted file mode 100644 index eb73969..0000000 --- a/packages/idempotency/src/index.browser.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Browser entry: the client half only — minting a key, fingerprinting a request, and the `fetch` - * wrapper that keeps one key stable across retries. - * - * The server-side manager is absent rather than stubbed. It needs a record store and a - * distributed lock, neither of which exists in a browser, and a noop stand-in would be the worst - * possible shape: idempotency that looks wired up and guarantees nothing. Importing - * `provideIdempotencyManager` in browser-resolved code is therefore a type error, which is the - * intended feedback. - * - * Splitting the package this way is what makes the "mint outside the retry loop" rule - * enforceable in the place it actually gets broken — the retrying client. - */ -export * from "./client.js"; -export * from "./fingerprint.js"; -export * from "./key.js"; diff --git a/packages/idempotency/src/index.node.ts b/packages/idempotency/src/index.node.ts deleted file mode 100644 index 27a34ab..0000000 --- a/packages/idempotency/src/index.node.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Node entry: the client half (key minting, fingerprints, the `fetch` wrapper) plus the - * server-side {@link IdempotencyManager} the browser entry deliberately does not carry. - * - * The asymmetry is the point of the split — see the package README. A browser has no record - * store and no distributed lock, so a manager there could only pretend; a server, meanwhile, - * needs the client half too, since it is a client of other services. - */ -export * from "./client.js"; -export * from "./config.js"; -export * from "./fingerprint.js"; -export * from "./instruments.js"; -export * from "./key.js"; -export * from "./record.js"; -export * from "./manager.node.js"; -export * from "./with-lock.node.js"; diff --git a/packages/idempotency/src/instruments.ts b/packages/idempotency/src/instruments.ts deleted file mode 100644 index d411842..0000000 --- a/packages/idempotency/src/instruments.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { - makeMetrics, - type Metrics, - type ObservabilityDeps, -} from "@primandproper/observability"; - -type Counter = ReturnType; - -/** - * What a manager reports. Idempotency is a control that fails silently when it fails at all, so - * the instruments are the only way to learn it has stopped working. - * - * `claimsLost` is the one to alert on: it is the only remaining path to a duplicate effect, and - * it always means the same thing — `inFlightTtlMs` is too short for the work it guards. - * - * There is no latency instrument here on purpose: `Observer.run` already records - * `operation.duration{operation="run"}` for every call, and a second histogram over the same - * span would be the same number under two names. - */ -export interface IdempotencyInstruments { - /** Every resolved call, tagged `outcome` (`executed`/`replayed`/`in_flight`/`mismatch`). */ - requests: Counter; - /** Work outran its claim and the key was taken by someone else. The alert. */ - claimsLost: Counter; - /** The effect happened, the record did not land, and a retry will run the work again. */ - recordFailures: Counter; - /** Store health: a read or write the record store refused. */ - storeErrors: Counter; - /** Records ignored for carrying another version — expect one spike after a shape change. */ - staleRecords: Counter; -} - -/** Builds the manager's instruments, defaulting to the noop meter. */ -export function idempotencyInstruments( - o11yName: string, - deps: ObservabilityDeps | undefined, -): IdempotencyInstruments { - const metrics = makeMetrics(o11yName, deps?.metrics); - return { - requests: metrics.counter("idempotency.requests", { - description: - "Count of resolved idempotent calls, tagged by outcome (executed/replayed/in_flight/mismatch).", - }), - claimsLost: metrics.counter("idempotency.claims.lost", { - description: - "Count of executions whose claim was taken by someone else before they could complete it.", - }), - recordFailures: metrics.counter("idempotency.record.failures", { - description: - "Count of completed executions whose result could not be recorded, so a retry will re-run the work.", - }), - storeErrors: metrics.counter("idempotency.store.errors", { - description: "Count of record-store reads or writes that failed.", - }), - staleRecords: metrics.counter("idempotency.stale.records", { - description: - "Count of stored records ignored for carrying a different record version.", - }), - }; -} diff --git a/packages/idempotency/src/key.test.ts b/packages/idempotency/src/key.test.ts deleted file mode 100644 index 2962fe1..0000000 --- a/packages/idempotency/src/key.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { isPlatformError } from "@primandproper/errors"; -import { describe, expect, it } from "vitest"; - -import { - DEFAULT_MAX_KEY_LENGTH, - IdempotencyErrorCode, - newIdempotencyKey, - parseIdempotencyKey, - validateIdempotencyKey, -} from "./key.js"; - -/** Asserts `fn` throws a PlatformError carrying `code`. */ -function expectCode(fn: () => unknown, code: string): void { - try { - fn(); - } catch (err) { - expect(isPlatformError(err, code)).toBe(true); - return; - } - expect.unreachable(`expected a ${code} error`); -} - -describe("newIdempotencyKey", () => { - it("mints a usable key", () => { - const key = newIdempotencyKey(); - expect(key).not.toBe(""); - expect(() => { - validateIdempotencyKey(key); - }).not.toThrow(); - }); - - it("mints a distinct key per call — which is why it belongs outside the retry loop", () => { - expect(newIdempotencyKey()).not.toBe(newIdempotencyKey()); - }); - - it("takes an injectable generator", () => { - expect(newIdempotencyKey({ generate: () => "pinned" })).toBe("pinned"); - }); -}); - -describe("validateIdempotencyKey", () => { - it("accepts the shapes third-party clients actually send", () => { - for (const key of [ - "3f0b1d5e-7c1a-4a5e-9d1e-2b3c4d5e6f70", - "cv0j2q9c00000356m1abcdefg", - "abc-_.~123", - ]) { - expect(() => { - validateIdempotencyKey(key); - }).not.toThrow(); - } - }); - - it("rejects an empty key", () => { - expectCode(() => { - validateIdempotencyKey(""); - }, IdempotencyErrorCode.keyRequired); - }); - - it("rejects a key past the maximum length", () => { - expectCode(() => { - validateIdempotencyKey("a".repeat(DEFAULT_MAX_KEY_LENGTH + 1)); - }, IdempotencyErrorCode.keyTooLong); - }); - - it("honours a custom maximum, and a non-positive one disables the check", () => { - expectCode(() => { - validateIdempotencyKey("abcd", 3); - }, IdempotencyErrorCode.keyTooLong); - expect(() => { - validateIdempotencyKey("a".repeat(10_000), 0); - }).not.toThrow(); - }); - - it("rejects characters that travel badly in a header or a store key", () => { - for (const key of ["with space", "tab\there", "newline\n", "nul\0", "emoji🙂", "é"]) { - expectCode(() => { - validateIdempotencyKey(key); - }, IdempotencyErrorCode.keyInvalid); - } - }); -}); - -describe("parseIdempotencyKey", () => { - it("returns the branded key when valid", () => { - expect(parseIdempotencyKey("abc123")).toBe("abc123"); - }); - - it("throws on an unusable key rather than branding it", () => { - expectCode(() => parseIdempotencyKey("bad key"), IdempotencyErrorCode.keyInvalid); - }); -}); diff --git a/packages/idempotency/src/key.ts b/packages/idempotency/src/key.ts deleted file mode 100644 index 9d28c4c..0000000 --- a/packages/idempotency/src/key.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { PlatformError } from "@primandproper/errors"; -import { provideIdentifierGenerator } from "@primandproper/identifiers"; - -/** - * A client-minted identifier for a *logical operation*, so a retry of it can be recognised as - * the same operation rather than as a new one. - * - * Branded rather than a bare `string` alias. {@link IdempotencyManager.run} takes a key and a - * {@link Fingerprint} adjacently and both are strings underneath: as aliases a transposed pair - * type-checks, runs, and silently disables mismatch detection — every request would - * fingerprint-match itself, so one key reused for two different requests would replay the first - * answer instead of being reported. A security control failing open with no signal is worth one - * conversion at the wire boundary ({@link parseIdempotencyKey}). - */ -export type IdempotencyKey = string & { readonly __brand: "idempotency-key" }; - -/** - * Identifies *what* the operation was, so a key reused for a different request can be detected. - * Branded for the same reason as {@link IdempotencyKey}; build one with the helpers in - * `fingerprint.ts` rather than casting. - */ -export type Fingerprint = string & { readonly __brand: "idempotency-fingerprint" }; - -/** The request header both halves of this package agree on, matching what Stripe publishes. */ -export const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"; - -/** The longest key accepted by default, matching the limit Stripe publishes for the same header. */ -export const DEFAULT_MAX_KEY_LENGTH = 255; - -/** Error codes thrown by this package. Namespaced per the `errors` package's open-code stance. */ -export const IdempotencyErrorCode = { - /** An empty key was supplied. */ - keyRequired: "idempotency/key-required", - /** The key is longer than the configured maximum. */ - keyTooLong: "idempotency/key-too-long", - /** The key contains bytes outside printable, space-free ASCII. */ - keyInvalid: "idempotency/key-invalid", - /** `run` was called without a fingerprint, which would disable mismatch detection entirely. */ - fingerprintRequired: "idempotency/fingerprint-required", - /** The record store could not be reached and the manager is configured to fail closed. */ - storeUnavailable: "idempotency/store-unavailable", -} as const; - -/** Injectable key generation, so a test can pin the minted value. */ -export interface KeyDeps { - /** Produces the raw string a fresh key is built from. Defaults to a nanoid. */ - generate?: () => string; -} - -const defaultGenerator = provideIdentifierGenerator(); - -/** - * Mints a fresh key. - * - * Call it **once per logical operation, outside any retry loop**. That placement is the whole - * contract: every attempt must send the same key, which is what lets the server recognise a - * retry. A key minted inside the loop is a new key per attempt — it looks like protection and - * provides none, and nothing on the server can detect the mistake, because a retry and a - * deliberate duplicate are byte-identical. - * - * There is deliberately no round trip to acquire a key: a request that times out never returns - * anything, which is precisely the case this package exists for. - */ -export function newIdempotencyKey(deps: KeyDeps = {}): IdempotencyKey { - const generate = deps.generate ?? (() => defaultGenerator.generate()); - return generate() as IdempotencyKey; -} - -/** - * Validates a client-supplied key, throwing a {@link PlatformError} when it is unusable. - * - * A key becomes both a store key and a lock key, so it is restricted rather than escaped: - * printable ASCII with no spaces, which admits the UUIDs, nanoids, and base64url tokens clients - * actually send while excluding control characters and anything that travels badly in a header. - * - * `identifiers`' own `isValid` is deliberately not used: it accepts only the scheme it generates, - * and keys arriving here are minted by third-party clients — rejecting a well-formed UUID would - * break every caller doing the ordinary thing. A non-positive `maxLength` disables the length - * check. - */ -export function validateIdempotencyKey( - key: string, - maxLength: number = DEFAULT_MAX_KEY_LENGTH, -): void { - if (key === "") { - throw new PlatformError(IdempotencyErrorCode.keyRequired, "empty idempotency key"); - } - if (maxLength > 0 && key.length > maxLength) { - throw new PlatformError( - IdempotencyErrorCode.keyTooLong, - `idempotency key exceeds the maximum length of ${String(maxLength)}`, - ); - } - for (const char of key) { - // Code units, not code points: the check is over the wire representation, and anything - // outside printable ASCII is rejected whole rather than decoded. - const code = char.charCodeAt(0); - if (char.length > 1 || code <= 0x20 || code > 0x7e) { - throw new PlatformError( - IdempotencyErrorCode.keyInvalid, - "idempotency key contains disallowed characters", - ); - } - } -} - -/** - * Validates a key arriving over the wire and brands it. The conversion point the branded types - * exist for — a handler reads the header, parses it here, and passes a typed value onward. - */ -export function parseIdempotencyKey( - key: string, - maxLength: number = DEFAULT_MAX_KEY_LENGTH, -): IdempotencyKey { - validateIdempotencyKey(key, maxLength); - return key as IdempotencyKey; -} - -/** - * Brands an already-computed fingerprint. For callers that compute their own digest instead of - * using this package's helpers; an empty fingerprint is rejected by `run` rather than here. - */ -export function asFingerprint(fingerprint: string): Fingerprint { - return fingerprint as Fingerprint; -} diff --git a/packages/idempotency/src/manager.node.test.ts b/packages/idempotency/src/manager.node.test.ts deleted file mode 100644 index 19c45d6..0000000 --- a/packages/idempotency/src/manager.node.test.ts +++ /dev/null @@ -1,555 +0,0 @@ -import { provideCache, type Cache } from "@primandproper/cache"; -import { - MemoryDistributedLock, - NoopDistributedLock, - type DistributedLock, -} from "@primandproper/distributedlock"; -import { isPlatformError } from "@primandproper/errors"; -import { makeRecordingObserver } from "@primandproper/observability"; -import { describe, expect, it, vi } from "vitest"; - -import type { IdempotencyConfigInput } from "./config.js"; -import { asFingerprint, IdempotencyErrorCode, parseIdempotencyKey } from "./key.js"; -import { - provideIdempotencyManager, - type IdempotencyDeps, - type IdempotencyManager, -} from "./manager.node.js"; -import { RECORD_VERSION, type IdempotencyRecord } from "./record.js"; - -interface Receipt { - chargeId: string; - charged: boolean; -} - -const KEY = parseIdempotencyKey("client-minted-key"); -const FINGERPRINT = asFingerprint("fp-charge-100"); -const OTHER_FINGERPRINT = asFingerprint("fp-charge-999"); - -/** A controllable clock, so claim expiry is exercised without real time passing. */ -function fakeClock(start = 1_000_000): { - now: () => number; - advance: (ms: number) => void; -} { - let t = start; - return { - now: () => t, - advance: (ms) => { - t += ms; - }, - }; -} - -/** - * A record store with a clock we control and failures we can switch on. The real memory cache - * expires against `Date.now`, which claim-expiry tests need to move faster than. - */ -class FakeStore implements Cache { - readonly entries = new Map(); - /** When set, every read and write rejects with it — the store being unreachable. */ - failure: Error | undefined; - readonly writes: T[] = []; - - constructor(private readonly now: () => number) {} - - get(key: string): Promise { - if (this.failure) return Promise.reject(this.failure); - const entry = this.entries.get(key); - if (entry === undefined) return Promise.resolve(undefined); - if (entry.expiresAt !== undefined && entry.expiresAt <= this.now()) { - this.entries.delete(key); - return Promise.resolve(undefined); - } - return Promise.resolve(entry.value); - } - - set(key: string, value: T, opts?: { ttlMs?: number }): Promise { - if (this.failure) return Promise.reject(this.failure); - this.writes.push(value); - this.entries.set(key, { - value, - expiresAt: - opts?.ttlMs !== undefined && opts.ttlMs > 0 ? this.now() + opts.ttlMs : undefined, - }); - return Promise.resolve(); - } - - delete(key: string): Promise { - if (this.failure) return Promise.reject(this.failure); - this.entries.delete(key); - return Promise.resolve(); - } - - ping(): Promise { - return Promise.resolve(); - } - - close(): Promise { - return Promise.resolve(); - } - - /** The single record under test, whatever key prefix the manager applied. */ - only(): T | undefined { - return [...this.entries.values()][0]?.value; - } -} - -/** The manager under test, wired to a fake clock and instant (fake) claim-lock polling. */ -function makeManager( - overrides: { - config?: IdempotencyConfigInput; - store?: Cache>; - lock?: DistributedLock; - deps?: Partial>; - clock?: { now: () => number; advance: (ms: number) => void }; - } = {}, -): { - manager: IdempotencyManager; - store: Cache>; - clock: { now: () => number; advance: (ms: number) => void }; -} { - const clock = overrides.clock ?? fakeClock(); - const store = overrides.store ?? new FakeStore>(clock.now); - const manager = provideIdempotencyManager(overrides.config, { - store, - lock: overrides.lock ?? new MemoryDistributedLock({}, { now: clock.now }), - now: clock.now, - // Waiting on a contended claim lock must not spend real time in tests; the clock still - // advances so a wait budget is honoured. - sleep: async (ms: number) => { - clock.advance(ms); - }, - ...overrides.deps, - }); - return { manager, store, clock }; -} - -const receipt = (chargeId = "ch_1"): Receipt => ({ chargeId, charged: true }); - -describe("IdempotencyManager.run", () => { - it("runs the work and reports it executed", async () => { - const { manager } = makeManager(); - const work = vi.fn(() => receipt()); - - await expect(manager.run(KEY, FINGERPRINT, work)).resolves.toEqual({ - status: "executed", - value: receipt(), - }); - expect(work).toHaveBeenCalledOnce(); - }); - - it("replays a recorded result instead of running the work again", async () => { - const { manager } = makeManager(); - const work = vi.fn(() => receipt()); - - await manager.run(KEY, FINGERPRINT, work); - const second = await manager.run(KEY, FINGERPRINT, work); - - expect(second).toEqual({ status: "replayed", value: receipt() }); - expect(work).toHaveBeenCalledOnce(); - }); - - it("replays through real providers, not just the fake store", async () => { - const manager = provideIdempotencyManager(undefined, { - store: provideCache>({ provider: "memory" }), - lock: new MemoryDistributedLock(), - }); - const work = vi.fn(() => receipt()); - - await manager.run(KEY, FINGERPRINT, work); - - await expect(manager.run(KEY, FINGERPRINT, work)).resolves.toEqual({ - status: "replayed", - value: receipt(), - }); - expect(work).toHaveBeenCalledOnce(); - }); - - it("reports a fingerprint mismatch rather than replaying the earlier answer", async () => { - const { manager } = makeManager(); - await manager.run(KEY, FINGERPRINT, () => receipt()); - const work = vi.fn(() => receipt("ch_2")); - - await expect(manager.run(KEY, OTHER_FINGERPRINT, work)).resolves.toEqual({ - status: "fingerprint-mismatch", - }); - expect(work).not.toHaveBeenCalled(); - }); - - it("reports a mismatch on a key reused mid-flight, in preference to in-flight", async () => { - // A client reusing one key for two different requests has a bug worth surfacing now; - // answering "in flight" would tell it to retry, which is the one thing that cannot help. - const { manager, store } = makeManager(); - let release = (): void => undefined; - const first = manager.run( - KEY, - FINGERPRINT, - () => - new Promise((resolve) => { - release = () => { - resolve(receipt()); - }; - }), - ); - await vi.waitFor(() => { - expect((store as FakeStore>).only()?.state).toBe( - "in-flight", - ); - }); - - await expect(manager.run(KEY, OTHER_FINGERPRINT, () => receipt())).resolves.toEqual({ - status: "fingerprint-mismatch", - }); - - release(); - await first; - }); - - it("refuses a second caller while the first is still running", async () => { - const { manager, store } = makeManager(); - let release = (): void => undefined; - const work = vi.fn( - () => - new Promise((resolve) => { - release = () => { - resolve(receipt()); - }; - }), - ); - - const first = manager.run(KEY, FINGERPRINT, work); - await vi.waitFor(() => { - expect((store as FakeStore>).only()?.state).toBe( - "in-flight", - ); - }); - const second = await manager.run(KEY, FINGERPRINT, work); - - expect(second).toEqual({ status: "in-flight" }); - expect(work).toHaveBeenCalledOnce(); - - release(); - await expect(first).resolves.toEqual({ status: "executed", value: receipt() }); - }); - - it("lets only one of two concurrent callers execute", async () => { - const { manager } = makeManager({ config: { lockWaitMs: 0 } }); - const work = vi.fn(async () => { - await Promise.resolve(); - return receipt(); - }); - - const [a, b] = await Promise.all([ - manager.run(KEY, FINGERPRINT, work), - manager.run(KEY, FINGERPRINT, work), - ]); - - expect(work).toHaveBeenCalledOnce(); - // One executes; the other is refused or replays, never a second execution. - expect([a.status, b.status].sort()).toEqual(["executed", "in-flight"]); - }); - - it("releases the claim when the work throws, so the next attempt runs again", async () => { - const { manager, store } = makeManager(); - const failing = vi.fn(() => { - throw new Error("charge declined"); - }); - - await expect(manager.run(KEY, FINGERPRINT, failing)).rejects.toThrow( - "charge declined", - ); - expect((store as FakeStore>).entries.size).toBe(0); - - await expect(manager.run(KEY, FINGERPRINT, () => receipt())).resolves.toEqual({ - status: "executed", - value: receipt(), - }); - }); - - it("re-runs after the claim expires, and the stale execution cannot overwrite the new owner", async () => { - // The one path left to a duplicate effect: work that outran inFlightTtlMs. What must not - // also happen is the stale execution writing its result into a slot it no longer owns. - const clock = fakeClock(); - const { manager, store } = makeManager({ - clock, - config: { inFlightTtlMs: 1_000 }, - deps: { - generateClaimId: (() => { - let n = 0; - return () => `claim-${String(++n)}`; - })(), - }, - }); - const fake = store as FakeStore>; - let release = (): void => undefined; - const slow = manager.run( - KEY, - FINGERPRINT, - () => - new Promise((resolve) => { - release = () => { - resolve(receipt("ch_slow")); - }; - }), - ); - await vi.waitFor(() => { - expect(fake.only()?.claimId).toBe("claim-1"); - }); - - clock.advance(1_001); - await expect( - manager.run(KEY, FINGERPRINT, () => receipt("ch_fast")), - ).resolves.toEqual({ - status: "executed", - value: receipt("ch_fast"), - }); - - release(); - await expect(slow).resolves.toEqual({ - status: "executed", - value: receipt("ch_slow"), - }); - // The re-claimer's record survives: the stale execution skipped the write. - expect(fake.only()).toMatchObject({ - claimId: "claim-2", - state: "completed", - value: receipt("ch_fast"), - }); - }); - - it("declines to record a result the recordable predicate rejects", async () => { - // "That failure was ours, not theirs": pinning it for the whole TTL would strand a client - // that could have succeeded on retry. - const { manager, store } = makeManager({ - deps: { recordable: (value: Receipt) => value.charged }, - }); - const declined: Receipt = { chargeId: "", charged: false }; - - await expect(manager.run(KEY, FINGERPRINT, () => declined)).resolves.toEqual({ - status: "executed", - value: declined, - }); - expect((store as FakeStore>).entries.size).toBe(0); - - await expect(manager.run(KEY, FINGERPRINT, () => receipt())).resolves.toEqual({ - status: "executed", - value: receipt(), - }); - }); - - it("ignores a record written by another version rather than misreading it", async () => { - const clock = fakeClock(); - const store = new FakeStore>(clock.now); - await store.set("idempotency:" + KEY, { - version: RECORD_VERSION + 1, - state: "completed", - createdAt: new Date(clock.now()).toISOString(), - fingerprint: FINGERPRINT, - claimId: "from-the-old-deploy", - value: receipt("ch_old"), - }); - const { manager } = makeManager({ clock, store }); - - await expect(manager.run(KEY, FINGERPRINT, () => receipt("ch_new"))).resolves.toEqual( - { - status: "executed", - value: receipt("ch_new"), - }, - ); - expect(store.only()).toMatchObject({ - version: RECORD_VERSION, - value: receipt("ch_new"), - }); - }); - - it("refuses a record in a state it does not understand", async () => { - const clock = fakeClock(); - const store = new FakeStore>(clock.now); - await store.set("idempotency:" + KEY, { - version: RECORD_VERSION, - state: "half-done" as IdempotencyRecord["state"], - createdAt: new Date(clock.now()).toISOString(), - fingerprint: FINGERPRINT, - claimId: "c1", - }); - const { manager } = makeManager({ clock, store }); - const work = vi.fn(() => receipt()); - - await expect(manager.run(KEY, FINGERPRINT, work)).resolves.toEqual({ - status: "in-flight", - }); - expect(work).not.toHaveBeenCalled(); - }); - - it("writes the claim and the result with their own TTLs", async () => { - const { manager, store } = makeManager({ - config: { inFlightTtlMs: 1_000, ttlMs: 60_000 }, - }); - const fake = store as FakeStore>; - const setSpy = vi.spyOn(fake, "set"); - - await manager.run(KEY, FINGERPRINT, () => receipt()); - - expect(setSpy.mock.calls.map(([, , opts]) => opts?.ttlMs)).toEqual([1_000, 60_000]); - }); - - it("takes a per-call retention override", async () => { - const { manager, store } = makeManager({ config: { ttlMs: 60_000 } }); - const setSpy = vi.spyOn(store as FakeStore>, "set"); - - await manager.run(KEY, FINGERPRINT, () => receipt(), { ttlMs: 5_000 }); - - expect(setSpy.mock.calls[1]?.[2]?.ttlMs).toBe(5_000); - }); - - it("rejects a key past the configured maximum before running anything", async () => { - const { manager } = makeManager({ config: { maxKeyLength: 8 } }); - const work = vi.fn(() => receipt()); - - await expect( - manager.run(parseIdempotencyKey("way-too-long-to-accept"), FINGERPRINT, work), - ).rejects.toSatisfy((err) => isPlatformError(err, IdempotencyErrorCode.keyTooLong)); - expect(work).not.toHaveBeenCalled(); - }); - - it("rejects an empty fingerprint rather than defaulting it", async () => { - // A default would make every request for a key look identical and disable mismatch - // detection entirely. - const { manager } = makeManager(); - const work = vi.fn(() => receipt()); - - await expect(manager.run(KEY, asFingerprint(""), work)).rejects.toSatisfy((err) => - isPlatformError(err, IdempotencyErrorCode.fingerprintRequired), - ); - expect(work).not.toHaveBeenCalled(); - }); - - it("observes the key, the fingerprint, and the replay", async () => { - const observer = makeRecordingObserver(); - const { manager } = makeManager({ deps: { observer } }); - - await manager.run(KEY, FINGERPRINT, () => receipt()); - await manager.run(KEY, FINGERPRINT, () => receipt()); - - expect(observer.observations).toContainEqual( - expect.objectContaining({ key: "idempotency.key", value: KEY }), - ); - expect(observer.observations).toContainEqual( - expect.objectContaining({ key: "idempotency.replayed", value: true }), - ); - }); -}); - -describe("store failure policy", () => { - it("fail-closed refuses the request rather than risking a duplicate effect", async () => { - const clock = fakeClock(); - const store = new FakeStore>(clock.now); - const { manager } = makeManager({ clock, store }); - store.failure = new Error("redis is down"); - const work = vi.fn(() => receipt()); - - await expect(manager.run(KEY, FINGERPRINT, work)).rejects.toSatisfy((err) => - isPlatformError(err, IdempotencyErrorCode.storeUnavailable), - ); - expect(work).not.toHaveBeenCalled(); - }); - - it("fail-open treats a failed read as a miss and runs the work", async () => { - const clock = fakeClock(); - const store = new FakeStore>(clock.now); - // Reads fail, writes still land — the shape fail-open exists for. - vi.spyOn(store, "get").mockRejectedValue(new Error("redis read timeout")); - const { manager } = makeManager({ - clock, - store, - config: { storeFailurePolicy: "fail-open" }, - }); - const work = vi.fn(() => receipt()); - - await expect(manager.run(KEY, FINGERPRINT, work)).resolves.toEqual({ - status: "executed", - value: receipt(), - }); - expect(work).toHaveBeenCalledOnce(); - }); - - it("refuses a failed claim write under either policy — the policy governs reads only", async () => { - // A claim that could not be written leaves the completion nothing to prove ownership - // against, so there is no state in which running the work is a defensible guess. Matching - // platform-go, which lets a Set failure fail the request regardless of FailOpen. - for (const storeFailurePolicy of ["fail-closed", "fail-open"] as const) { - const clock = fakeClock(); - const store = new FakeStore>(clock.now); - vi.spyOn(store, "set").mockRejectedValue(new Error("redis is down")); - const { manager } = makeManager({ clock, store, config: { storeFailurePolicy } }); - const work = vi.fn(() => receipt()); - - await expect(manager.run(KEY, FINGERPRINT, work)).rejects.toSatisfy((err) => - isPlatformError(err, IdempotencyErrorCode.storeUnavailable), - ); - expect(work).not.toHaveBeenCalled(); - } - }); - - it("refuses when the locker itself fails, under either policy", async () => { - const lock: DistributedLock = { - acquire: () => Promise.reject(new Error("lock store down")), - ping: () => Promise.resolve(), - close: () => Promise.resolve(), - }; - - for (const storeFailurePolicy of ["fail-closed", "fail-open"] as const) { - const { manager } = makeManager({ lock, config: { storeFailurePolicy } }); - const work = vi.fn(() => receipt()); - - await expect(manager.run(KEY, FINGERPRINT, work)).rejects.toSatisfy((err) => - isPlatformError(err, IdempotencyErrorCode.storeUnavailable), - ); - expect(work).not.toHaveBeenCalled(); - } - }); - - it("fail-open on a wholly unreachable store still refuses, because the claim cannot land", async () => { - const clock = fakeClock(); - const store = new FakeStore>(clock.now); - const { manager } = makeManager({ - clock, - store, - config: { storeFailurePolicy: "fail-open" }, - }); - store.failure = new Error("redis is down"); - const work = vi.fn(() => receipt()); - - await expect(manager.run(KEY, FINGERPRINT, work)).rejects.toSatisfy((err) => - isPlatformError(err, IdempotencyErrorCode.storeUnavailable), - ); - expect(work).not.toHaveBeenCalled(); - }); - - it("keeps the store error as the cause, so the operator can see what broke", async () => { - const clock = fakeClock(); - const store = new FakeStore>(clock.now); - const { manager } = makeManager({ clock, store }); - const cause = new Error("ECONNREFUSED"); - store.failure = cause; - - await expect(manager.run(KEY, FINGERPRINT, () => receipt())).rejects.toMatchObject({ - cause, - }); - }); -}); - -describe("the locker matters", () => { - it("still replays under the noop locker — but grants every claim", async () => { - // Documented, not endorsed: replay covers the ordinary timeout-then-retry case, while two - // genuinely concurrent requests can both claim and both execute. - const { manager } = makeManager({ lock: new NoopDistributedLock() }); - - await manager.run(KEY, FINGERPRINT, () => receipt()); - - await expect(manager.run(KEY, FINGERPRINT, () => receipt())).resolves.toEqual({ - status: "replayed", - value: receipt(), - }); - }); -}); diff --git a/packages/idempotency/src/manager.node.ts b/packages/idempotency/src/manager.node.ts deleted file mode 100644 index 096dae4..0000000 --- a/packages/idempotency/src/manager.node.ts +++ /dev/null @@ -1,519 +0,0 @@ -import type { Cache } from "@primandproper/cache"; -import type { DistributedLock } from "@primandproper/distributedlock"; -import { isPlatformError, messageOf, PlatformError } from "@primandproper/errors"; -import { provideIdentifierGenerator } from "@primandproper/identifiers"; -import { - makeObserver, - type ObservabilityDeps, - type Observer, - type Operation, -} from "@primandproper/observability"; - -import { IdempotencyConfigSchema, type IdempotencyConfigInput } from "./config.js"; -import { idempotencyInstruments, type IdempotencyInstruments } from "./instruments.js"; -import { - IdempotencyErrorCode, - validateIdempotencyKey, - type Fingerprint, - type IdempotencyKey, -} from "./key.js"; -import { - RECORD_VERSION, - type IdempotencyRecord, - type IdempotentResult, -} from "./record.js"; -import { withLock, type LockAttempt } from "./with-lock.node.js"; - -const o11yName = "idempotency"; - -/** - * What a manager needs at runtime. The store and the locker are required and have no defaults: - * an implicit noop locker would leave replay working while quietly removing mutual exclusion, - * which is the failure mode hardest to notice and most expensive to meet. - */ -export interface IdempotencyDeps extends ObservabilityDeps { - /** - * Where records live. Use a cross-process provider (redis) in production: the memory provider - * is per-process, so replicas would not see each other's records. - */ - store: Cache>; - /** - * The lock guarding the claim. Note that `noop` acquires unconditionally — with it, replay - * still works (which covers the ordinary timeout-then-retry case) but two genuinely concurrent - * requests can both claim and both execute. - */ - lock: DistributedLock; - /** - * Decides whether a result is worth recording. A result it rejects releases the claim instead, - * so the next attempt runs the work again. - * - * This is how a caller says "that failure was ours, not theirs": a server-side error usually - * means the effect did not land, and pinning it for the whole TTL would strand a client that - * could have succeeded on retry. Defaults to recording everything the work returns. - */ - recordable?: (value: T) => boolean; - /** Injectable clock for record timestamps and the claim-lock wait, for deterministic tests. */ - now?: () => number; - /** Injectable delay for the claim-lock wait, for deterministic tests. */ - sleep?: (ms: number) => Promise; - /** Injectable claim-id generation, for deterministic tests. */ - generateClaimId?: () => string; -} - -/** Per-call overrides. */ -export interface RunOptions { - /** - * Overrides how long *this* call's completed record is retained, in milliseconds. - * - * Retention is the window in which a retry replays instead of re-running, so it belongs to the - * operation rather than to the manager: a payment worth protecting for a day and a profile - * update worth protecting for a minute can then share one manager. There is deliberately no - * per-call `inFlightTtlMs` — that bounds how long a dead process blocks a retry, which is a - * property of the deployment rather than of the call. - */ - ttlMs?: number; -} - -/** Outcomes reported on `idempotency.requests`; the four together are the request total. */ -type Outcome = "executed" | "replayed" | "in_flight" | "mismatch"; - -/** What claiming a key produced. */ -type Claim = - /** The claim is ours, and `claimId` is what proves it at completion time. */ - | { kind: "claimed"; claimId: string } - /** Someone landed a record between the pre-lock read and the lock. */ - | { kind: "existing"; record: IdempotencyRecord } - /** The claim lock stayed held for the whole wait — someone else is claiming this key now. */ - | { kind: "contended" }; - -const defaultIdentifiers = provideIdentifierGenerator(); - -/** - * Runs work at most once per client-supplied key. - * - * A concrete class rather than an interface: there is one implementation, and the seams worth - * swapping — the store and the locker — are already interfaces of their own. - * - * What it guarantees is at-most-once **effect**, not exactly-once. The gap is worth naming: work - * that has its effect and *then* fails is not covered. The charge landed, the error came back, - * nothing was recorded, and the retry charges again. Recording failures instead would be worse — - * a transient error would be pinned for the whole TTL and the client could never succeed — which - * is what {@link IdempotencyDeps.recordable} exists to let a caller tune. - */ -export class IdempotencyManager { - readonly #store: Cache>; - readonly #lock: DistributedLock; - readonly #observer: Observer; - readonly #instruments: IdempotencyInstruments; - readonly #recordable: (value: T) => boolean; - readonly #now: () => number; - readonly #newClaimId: () => string; - readonly #keyPrefix: string; - readonly #ttlMs: number; - readonly #inFlightTtlMs: number; - readonly #maxKeyLength: number; - readonly #failOpen: boolean; - readonly #lockTtlMs: number; - readonly #lockWaitMs: number; - readonly #lockPollMs: number; - readonly #sleep: ((ms: number) => Promise) | undefined; - - constructor(config: IdempotencyConfigInput | undefined, deps: IdempotencyDeps) { - const cfg = IdempotencyConfigSchema.parse(config ?? {}); - this.#store = deps.store; - this.#lock = deps.lock; - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#instruments = idempotencyInstruments(o11yName, deps); - this.#recordable = deps.recordable ?? ((): boolean => true); - this.#now = deps.now ?? ((): number => Date.now()); - this.#newClaimId = - deps.generateClaimId ?? ((): string => defaultIdentifiers.generate()); - this.#sleep = deps.sleep; - this.#keyPrefix = cfg.keyPrefix; - this.#ttlMs = cfg.ttlMs; - this.#inFlightTtlMs = cfg.inFlightTtlMs; - this.#maxKeyLength = cfg.maxKeyLength; - this.#failOpen = cfg.storeFailurePolicy === "fail-open"; - this.#lockTtlMs = cfg.lockTtlMs; - this.#lockWaitMs = cfg.lockWaitMs; - this.#lockPollMs = cfg.lockPollMs; - } - - /** - * Runs `fn` at most once for `key`. - * - * The protocol is four steps, and the order is the correctness argument: - * - * 1. read the record — replay, refuse, or continue; - * 2. lock → **re-read** → write an in-flight claim → unlock; - * 3. run the work *outside* the lock; - * 4. record the result, or release the claim. - * - * The re-read inside the lock is what makes it correct: two callers that both missed the - * pre-lock read would otherwise both claim, and the second would overwrite the first. - * - * The work runs outside the lock because a held lock is a held resource (the postgres lock - * provider runs inside a transaction — an open transaction per in-flight request means pool - * exhaustion and blocked vacuums), because lock leases are shorter than real work (anything - * slower loses mutual exclusion *while still running*), and because a lock leaves no evidence: - * kill a process mid-execution and the lock evaporates, while a *record* with its own TTL - * survives and correctly refuses the retry until it expires. - * - * An error thrown by `fn` propagates unchanged and the claim is released, so the next attempt - * runs the work again. An unusable key, an empty fingerprint, or an unreachable store throw a - * {@link PlatformError}; the four ordinary outcomes are returned, not thrown. - */ - run( - key: IdempotencyKey, - fingerprint: Fingerprint, - fn: () => Promise | T, - options: RunOptions = {}, - ): Promise> { - return this.#observer.run("run", async (op) => { - validateIdempotencyKey(key, this.#maxKeyLength); - if (fingerprint === "") { - // Defaulting it would make every request for a key look identical and disable mismatch - // detection entirely, so it is rejected rather than filled in. - throw new PlatformError( - IdempotencyErrorCode.fingerprintRequired, - "empty idempotency fingerprint", - ); - } - - op.set("idempotency.key", key).set("idempotency.fingerprint", fingerprint); - - const ttlMs = - options.ttlMs !== undefined && options.ttlMs > 0 ? options.ttlMs : this.#ttlMs; - const storeKey = this.#storeKey(key); - - // Read before locking. The overwhelmingly common case is a replay of a completed record, - // and it costs one round trip with no coordination at all. - const existing = await this.#load(op, storeKey); - if (existing !== undefined) { - return this.#resolve(op, existing, fingerprint); - } - - const claim = await this.#claim(op, key, storeKey, fingerprint); - switch (claim.kind) { - case "existing": - return this.#resolve(op, claim.record, fingerprint); - case "contended": - // Someone else holds the claim lock for this key right now, so they are about to write - // (or have just written) an in-flight record. Refusing is the same answer that record - // would produce, one round trip earlier. - op.logger().debug("claim lock contended; reporting in flight"); - this.#count("in_flight"); - return { status: "in-flight" }; - case "claimed": - break; - } - - const { claimId } = claim; - - let value: T; - try { - value = await fn(); - } catch (err) { - await this.#release(op, storeKey, claimId); - throw err; - } - - if (!this.#recordable(value)) { - op.set("idempotency.recorded", false); - await this.#release(op, storeKey, claimId); - this.#count("executed"); - return { status: "executed", value }; - } - - await this.#commit(op, storeKey, claimId, fingerprint, value, ttlMs); - this.#count("executed"); - return { status: "executed", value }; - }); - } - - /** - * Turns a stored record into an answer. - * - * The fingerprint is checked before the state, deliberately: a client reusing one key for two - * different requests has a bug worth surfacing immediately, and answering `in-flight` instead - * would tell it to retry — the one thing that cannot help. - */ - #resolve( - op: Operation, - record: IdempotencyRecord, - fingerprint: Fingerprint, - ): IdempotentResult { - if (record.fingerprint !== fingerprint) { - op.logger().warn("idempotency key reused with a different request"); - this.#count("mismatch"); - return { status: "fingerprint-mismatch" }; - } - - switch (record.state) { - case "completed": - op.set("idempotency.replayed", true); - this.#count("replayed"); - // A completed record without a value is one whose work returned `undefined`; the state - // is what says it finished, so the value is replayed as it was stored. - return { status: "replayed", value: record.value as T }; - case "in-flight": - this.#count("in_flight"); - return { status: "in-flight" }; - default: - // A state this build does not know is treated like a shape it cannot read: refuse rather - // than guess, since the alternative is running work that may already have run. - this.#instruments.staleRecords.add(1); - op.logger().warn("ignoring idempotency record in an unknown state"); - this.#count("in_flight"); - return { status: "in-flight" }; - } - } - - /** - * Writes the in-flight record under the lock, returning either the claim id it took or the - * record that made claiming unnecessary. The lock covers a re-read and a write and nothing - * else. - */ - async #claim( - op: Operation, - key: IdempotencyKey, - storeKey: string, - fingerprint: Fingerprint, - ): Promise> { - let attempt: LockAttempt>; - try { - attempt = await withLock>( - this.#lock, - this.#lockKey(key), - async () => { - const existing = await this.#load(op, storeKey); - if (existing !== undefined) { - return { kind: "existing", record: existing }; - } - - const claimId = this.#newClaimId(); - await this.#store.set( - storeKey, - { - version: RECORD_VERSION, - state: "in-flight", - createdAt: new Date(this.#now()).toISOString(), - fingerprint, - claimId, - }, - { ttlMs: this.#inFlightTtlMs }, - ); - return { kind: "claimed", claimId }; - }, - { - ttlMs: this.#lockTtlMs, - waitMs: this.#lockWaitMs, - pollMs: this.#lockPollMs, - now: this.#now, - onReleaseError: (err) => { - op.acknowledge(err, "releasing idempotency claim lock"); - }, - ...(this.#sleep !== undefined ? { sleep: this.#sleep } : {}), - }, - ); - } catch (err) { - // A read inside the lock that already went through the policy is re-thrown untouched; - // wrapping it again would double-count the store error and bury the original message. - if (isPlatformError(err, IdempotencyErrorCode.storeUnavailable)) { - throw err; - } - // Either the locker failed or the claim write did, and neither consults the store-failure - // policy: it governs *reads* only. A read can fail open because "no record" is a coherent - // (if unprotected) answer to carry on from; a claim that could not be written leaves - // nothing for the completion to prove ownership against, so there is no state in which - // running the work is a defensible guess. platform-go draws the line in the same place. - this.#instruments.storeErrors.add(1); - throw new PlatformError( - IdempotencyErrorCode.storeUnavailable, - `claiming idempotency key: ${messageOf(err)}`, - { cause: err }, - ); - } - - return attempt.acquired ? attempt.value : { kind: "contended" }; - } - - /** - * Records a finished result, but only if the claim is still ours. - * - * A failure here is counted and logged, never thrown: the work already happened and the caller - * is entitled to its result. What the caller loses is the replay, so the next attempt runs the - * work again — which is exactly what `idempotency.record.failures` is for. - */ - async #commit( - op: Operation, - storeKey: string, - claimId: string, - fingerprint: Fingerprint, - value: T, - ttlMs: number, - ): Promise { - if (!(await this.#stillOurs(op, storeKey, claimId, "completing"))) { - return; - } - - try { - await this.#store.set( - storeKey, - { - version: RECORD_VERSION, - state: "completed", - createdAt: new Date(this.#now()).toISOString(), - fingerprint, - claimId, - value, - }, - { ttlMs }, - ); - op.set("idempotency.recorded", true); - } catch (err) { - this.#instruments.recordFailures.add(1); - op.acknowledge(err, "recording idempotency result"); - } - } - - /** - * Drops our claim so the next attempt can run the work again. - * - * Best-effort by design: if it fails, the claim expires on its own `inFlightTtlMs` and callers - * are refused until then. Surfacing the failure would replace a delay with an error for work - * that already completed. - */ - async #release(op: Operation, storeKey: string, claimId: string): Promise { - if (!(await this.#stillOurs(op, storeKey, claimId, "releasing"))) { - return; - } - - try { - await this.#store.delete(storeKey); - } catch (err) { - op.acknowledge(err, "releasing idempotency claim"); - } - } - - /** - * Reports whether the stored record is still the claim this execution took. - * - * It is false when the work outran `inFlightTtlMs`, the claim expired, and someone else - * re-claimed the key — the one remaining path to a duplicate effect, and the reason - * `idempotency.claims.lost` is the counter to alert on. Writing through it would compound the - * problem by handing the new owner a result from a different execution. - */ - async #stillOurs( - op: Operation, - storeKey: string, - claimId: string, - action: string, - ): Promise { - let record: IdempotencyRecord | undefined; - try { - record = await this.#load(op, storeKey); - } catch (err) { - this.#instruments.recordFailures.add(1); - op.acknowledge(err, `reading idempotency claim before ${action}`); - return false; - } - - // A read that failed open arrives here as `undefined` and is therefore counted as a lost - // claim. That is the honest reading: we could not confirm the claim is ours, and the write - // is skipped for the same reason it would be if someone else had taken it. - if (record?.claimId !== claimId) { - this.#instruments.claimsLost.add(1); - op.logger().warn( - "idempotency claim lost before it could be completed; the work may run again", - { "idempotency.claim_id": claimId, "idempotency.action": action }, - ); - return false; - } - - return true; - } - - /** - * Reads a record, reporting `undefined` when there is nothing usable to this build. - * - * A record written by a different version reads as absent rather than as an error: with a - * day-long TTL, failing on it would turn one bad deploy into a day of failures. - * - * **This is the only place the store-failure policy applies.** `fail-closed` throws; - * `fail-open` reports the read as a miss and carries on, which is what trades the guarantee for - * availability. The two are indistinguishable to the caller by design — "nothing recorded" is - * exactly the answer fail-open elects to proceed from. - */ - async #load( - op: Operation, - storeKey: string, - ): Promise | undefined> { - let record: IdempotencyRecord | undefined; - try { - record = await this.#store.get(storeKey); - } catch (err) { - this.#instruments.storeErrors.add(1); - if (!this.#failOpen) { - throw new PlatformError( - IdempotencyErrorCode.storeUnavailable, - `reading idempotency record: ${messageOf(err)}`, - { cause: err }, - ); - } - op.acknowledge(err, "reading idempotency record, failing open"); - return undefined; - } - - if (record === undefined) { - return undefined; - } - - if (record.version !== RECORD_VERSION) { - this.#instruments.staleRecords.add(1); - op.logger().debug( - "ignoring idempotency record written by a different record version", - { - "idempotency.record_version": record.version, - }, - ); - return undefined; - } - - return record; - } - - /** Records one resolved request against its outcome. */ - #count(outcome: Outcome): void { - this.#instruments.requests.add(1, { outcome }); - } - - /** Namespaces a caller's key for the record store. */ - #storeKey(key: IdempotencyKey): string { - return `${this.#keyPrefix}${key}`; - } - - /** - * Namespaces a caller's key for the locker. Deliberately distinct from the store key: the two - * live in different systems, and a shared spelling invites the assumption that one can be - * derived from the other. - */ - #lockKey(key: IdempotencyKey): string { - return `${this.#keyPrefix}lock:${key}`; - } -} - -/** - * Validates config (applying defaults) and returns an {@link IdempotencyManager}. The analogue of - * the Go platform's `idempotencycfg.NewManager`, minus the nested store/locker config: both are - * built by their own packages' factories and passed in, which is how this repo composes. - * - * The result type is the caller's, and it cannot be inferred from the arguments, so it is spelled - * out at the call site: `provideIdempotencyManager(cfg, { store, lock })`. - */ -export function provideIdempotencyManager( - config: IdempotencyConfigInput | undefined, - deps: IdempotencyDeps, -): IdempotencyManager { - return new IdempotencyManager(config, deps); -} diff --git a/packages/idempotency/src/record.test.ts b/packages/idempotency/src/record.test.ts deleted file mode 100644 index ac6c2a8..0000000 --- a/packages/idempotency/src/record.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { hasValue, type IdempotentResult } from "./record.js"; - -describe("hasValue", () => { - it("narrows the two outcomes that carry a value", () => { - const executed: IdempotentResult = { status: "executed", value: 1 }; - const replayed: IdempotentResult = { status: "replayed", value: 2 }; - - for (const result of [executed, replayed]) { - expect(hasValue(result)).toBe(true); - // The point of the guard: `result.value` is reachable without a cast in this branch. - if (hasValue(result)) { - expect(typeof result.value).toBe("number"); - } - } - }); - - it("rejects the two outcomes that do not", () => { - expect(hasValue({ status: "in-flight" })).toBe(false); - expect(hasValue({ status: "fingerprint-mismatch" })).toBe(false); - }); -}); diff --git a/packages/idempotency/src/record.ts b/packages/idempotency/src/record.ts deleted file mode 100644 index 11f87ae..0000000 --- a/packages/idempotency/src/record.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * The record shape this build writes and reads. - * - * Every record carries it, and a record stamped with anything else is **ignored rather than - * misread** — see {@link IdempotencyRecord.version}. Bump it whenever the stored shape changes. - */ -export const RECORD_VERSION = 1; - -/** The lifecycle stage of a record. */ -export type RecordState = - /** A claim: work has started and has not reported back. */ - | "in-flight" - /** A finished result, safe to replay. */ - | "completed"; - -/** - * What the store holds for a key. Written twice per execution: once to claim the key, once to - * record the outcome. - * - * `T` must survive the store's round trip. The redis cache provider serialises with JSON, so - * `Date`/`Map`/`Set` come back as their JSON shapes — keep `T` JSON-safe if the manager might - * ever be pointed at a provider other than memory. - */ -export interface IdempotencyRecord { - /** - * The record shape this was written with. A record written by a different version reads as a - * miss rather than as an error: with a day-long TTL, treating an unreadable record as a - * failure would turn one bad deploy into a day of failures. - */ - version: number; - /** The lifecycle stage. */ - state: RecordState; - /** When this revision of the record was written, ISO-8601. */ - createdAt: string; - /** Identifies the request this key was used for, so a *different* request can be detected. */ - fingerprint: string; - /** - * Identifies the execution that owns the claim. Only its owner may complete or release it, - * which is what stops an execution that outlived its claim from overwriting whoever - * re-claimed the key. - */ - claimId: string; - /** The recorded result. Present only once `state` is `completed`. */ - value?: T; -} - -/** - * The outcome of {@link IdempotencyManager.run}. - * - * All four are expected control flow, not failures, so none of them throw — the divergence from - * the Go platform's error sentinels, and the same optional-over-sentinels stance the cache takes - * for a miss. Thrown {@link PlatformError}s are reserved for genuine failures (an unreachable - * record store, an unusable key). - */ -export type IdempotentResult = - /** The work ran here, now. */ - | { status: "executed"; value: T } - /** A recorded result was replayed instead of re-running the work. */ - | { status: "replayed"; value: T } - /** - * The key names work that started elsewhere and has not reported back. Refused rather than - * re-run, because "did it happen?" is unanswerable and running it again is the worse guess. - */ - | { status: "in-flight" } - /** - * The key was already used for a *different* request. Reported rather than answered with the - * earlier result, which would hide a client bug. - */ - | { status: "fingerprint-mismatch" }; - -/** Narrows the two outcomes that carry a value, for callers that treat replay as success. */ -export function hasValue( - result: IdempotentResult, -): result is Extract, { value: T }> { - return result.status === "executed" || result.status === "replayed"; -} diff --git a/packages/idempotency/src/with-lock.node.test.ts b/packages/idempotency/src/with-lock.node.test.ts deleted file mode 100644 index a217a94..0000000 --- a/packages/idempotency/src/with-lock.node.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { MemoryDistributedLock } from "@primandproper/distributedlock"; -import { describe, expect, it, vi } from "vitest"; - -import { withLock } from "./with-lock.node.js"; - -/** A controllable clock, so wait budgets are exercised without real time passing. */ -function fakeClock(start = 0): { now: () => number; advance: (ms: number) => void } { - let t = start; - return { - now: () => t, - advance: (ms) => { - t += ms; - }, - }; -} - -describe("withLock", () => { - it("runs the callback while holding the lock and reports its value", async () => { - const lock = new MemoryDistributedLock(); - - const attempt = await withLock(lock, "k", async (held) => { - expect(held.key).toBe("k"); - // Held for the duration of the callback: nobody else may take it. - await expect(lock.acquire("k")).resolves.toBeUndefined(); - return "done"; - }); - - expect(attempt).toEqual({ acquired: true, value: "done" }); - }); - - it("releases the lock afterwards", async () => { - const lock = new MemoryDistributedLock(); - - await withLock(lock, "k", () => undefined); - - await expect(lock.acquire("k")).resolves.toBeDefined(); - }); - - it("releases the lock when the callback throws, and propagates the error", async () => { - const lock = new MemoryDistributedLock(); - - await expect( - withLock(lock, "k", () => { - throw new Error("boom"); - }), - ).rejects.toThrow("boom"); - await expect(lock.acquire("k")).resolves.toBeDefined(); - }); - - it("reports contention rather than throwing", async () => { - const lock = new MemoryDistributedLock(); - await lock.acquire("k"); - - await expect(withLock(lock, "k", () => "unreachable")).resolves.toEqual({ - acquired: false, - }); - }); - - it("retries a held key until the wait budget is spent", async () => { - const lock = new MemoryDistributedLock(); - await lock.acquire("k"); - const clock = fakeClock(); - const sleep = vi.fn(async (ms: number) => { - clock.advance(ms); - }); - - const attempt = await withLock(lock, "k", () => "unreachable", { - waitMs: 100, - pollMs: 25, - now: clock.now, - sleep, - }); - - expect(attempt).toEqual({ acquired: false }); - // 4 sleeps of 25ms exhaust the 100ms budget; the 5th attempt finds the deadline reached. - expect(sleep).toHaveBeenCalledTimes(4); - }); - - it("acquires on a later attempt when the holder releases", async () => { - const lock = new MemoryDistributedLock(); - const held = await lock.acquire("k"); - const clock = fakeClock(); - let attempts = 0; - const sleep = async (ms: number): Promise => { - clock.advance(ms); - attempts += 1; - if (attempts === 2) { - await held?.release(); - } - }; - - await expect( - withLock(lock, "k", () => "done", { - waitMs: 1_000, - pollMs: 25, - now: clock.now, - sleep, - }), - ).resolves.toEqual({ acquired: true, value: "done" }); - }); - - it("reports a failed release instead of masking the callback's result", async () => { - const lock = new MemoryDistributedLock(); - const acquire = lock.acquire.bind(lock); - vi.spyOn(lock, "acquire").mockImplementation(async (key, opts) => { - const held = await acquire(key, opts); - return held === undefined - ? undefined - : { - ...held, - release: () => Promise.reject(new Error("store down")), - }; - }); - const onReleaseError = vi.fn(); - - await expect(withLock(lock, "k", () => "done", { onReleaseError })).resolves.toEqual({ - acquired: true, - value: "done", - }); - expect(onReleaseError).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/idempotency/src/with-lock.node.ts b/packages/idempotency/src/with-lock.node.ts deleted file mode 100644 index 7f849ae..0000000 --- a/packages/idempotency/src/with-lock.node.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { DistributedLock, Lock } from "@primandproper/distributedlock"; - -/** The outcome of {@link withLock}: contention is reported, not thrown. */ -export type LockAttempt = - | { acquired: true; value: R } - /** The key stayed held by someone else for the whole wait budget. */ - | { acquired: false }; - -/** Options for {@link withLock}. */ -export interface WithLockOptions { - /** Lease duration for the acquired lock, in milliseconds. */ - ttlMs?: number; - /** How long to keep retrying a held key before giving up. `0` means a single attempt. */ - waitMs?: number; - /** How long to wait between attempts. */ - pollMs?: number; - /** Injectable clock, for deterministic tests. */ - now?: () => number; - /** Injectable delay, for deterministic tests. */ - sleep?: (ms: number) => Promise; - /** - * Called when releasing throws. Releasing happens in a `finally`, so letting it propagate - * would replace `fn`'s result — or `fn`'s own error — with a failure of the cleanup. The lease - * expires on its own ttl regardless, so the failure is reported here and otherwise ignored. - */ - onReleaseError?: (err: unknown) => void; -} - -const defaultSleep = (ms: number): Promise => - new Promise((resolve) => setTimeout(resolve, ms)); - -/** - * Runs `fn` while holding `key`, releasing the lock afterwards even if `fn` throws. - * - * `DistributedLock.acquire` reports contention as `undefined` immediately rather than blocking, - * so this retries until `waitMs` is exhausted before reporting `{ acquired: false }`. Contention - * is a return value rather than an exception for the same reason the underlying `acquire` makes - * it one: it is an expected outcome the caller must branch on, not a failure. - * - * The release is best-effort — a `false` from `release()` means the lease had already expired or - * been taken over, which `fn` overrunning `ttlMs` would cause; keep `fn` short. - */ -export async function withLock( - lock: DistributedLock, - key: string, - fn: (held: Lock) => Promise | R, - options: WithLockOptions = {}, -): Promise> { - const now = options.now ?? (() => Date.now()); - const sleep = options.sleep ?? defaultSleep; - const waitMs = options.waitMs ?? 0; - const pollMs = options.pollMs ?? 25; - const deadline = now() + waitMs; - - for (;;) { - const held = await lock.acquire( - key, - options.ttlMs !== undefined ? { ttlMs: options.ttlMs } : {}, - ); - if (held !== undefined) { - try { - return { acquired: true, value: await fn(held) }; - } finally { - try { - await held.release(); - } catch (err) { - options.onReleaseError?.(err); - } - } - } - if (now() >= deadline) { - return { acquired: false }; - } - await sleep(pollMs); - } -} diff --git a/packages/idempotency/tsconfig.json b/packages/idempotency/tsconfig.json deleted file mode 100644 index d86106a..0000000 --- a/packages/idempotency/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022", "DOM"], - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/idempotency/tsup.config.ts b/packages/idempotency/tsup.config.ts deleted file mode 100644 index a4142df..0000000 --- a/packages/idempotency/tsup.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig([ - { - entry: { "index.node": "src/index.node.ts" }, - platform: "node", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: true, - }, - { - entry: { "index.browser": "src/index.browser.ts" }, - platform: "browser", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: false, - }, -]); diff --git a/packages/llm/CHANGELOG.md b/packages/llm/CHANGELOG.md deleted file mode 100644 index a176618..0000000 --- a/packages/llm/CHANGELOG.md +++ /dev/null @@ -1,14 +0,0 @@ -# @primandproper/llm - -## 0.1.0 - -### Minor Changes - -- a124406: Add a required `completeStream()` method to the `LLMProvider` interface. Breaking for external `LLMProvider` implementers. Adds `timeoutMs`/`retry` config and request cancellation via `signal`. - -### Patch Changes - -- Updated dependencies [a124406] -- Updated dependencies [a124406] - - @primandproper/observability@0.1.0 - - @primandproper/retry@0.1.0 diff --git a/packages/llm/package.json b/packages/llm/package.json deleted file mode 100644 index 482e73c..0000000 --- a/packages/llm/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@primandproper/llm", - "version": "0.1.0", - "type": "module", - "description": "Server-only LLM completions: a Provider interface over Anthropic and OpenAI.", - "license": "AGPL-3.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/primandproper/platform-ts.git", - "directory": "packages/llm" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "eslint .", - "clean": "rm -rf dist .turbo" - }, - "dependencies": { - "@primandproper/observability": "workspace:*", - "@primandproper/retry": "workspace:*", - "zod": "3.25.76" - } -} diff --git a/packages/llm/src/config.ts b/packages/llm/src/config.ts deleted file mode 100644 index cc9cf05..0000000 --- a/packages/llm/src/config.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { RetryConfigSchema } from "@primandproper/retry"; -import { z } from "zod"; - -/** Thirty seconds — the default per-completion deadline shared by both REST providers. */ -const DEFAULT_TIMEOUT_MS = 30_000; - -/** - * Per-completion deadline in milliseconds; `0` disables it. Defaults on so a wedged vendor call - * fails fast instead of hanging on the SDK-less `fetch` (NET-1). - */ -const timeoutMs = z.number().int().nonnegative().default(DEFAULT_TIMEOUT_MS); - -/** - * Optional retry policy for transient completion failures (network/timeout errors and 429/5xx). - * Omitted means a single attempt — vendors document 429/5xx as retryable, but retry is opt-in so a - * duplicate generation isn't billed without the caller asking for it. - */ -const retry = RetryConfigSchema.optional(); - -/** Anthropic-provider config. `apiKey` is required; `model` defaults to a current Claude. */ -export const AnthropicLLMConfigSchema = z.object({ - apiKey: z.string(), - model: z.string().default("claude-sonnet-4-6"), - baseUrl: z.string().default("https://api.anthropic.com/v1/messages"), - maxTokens: z.number().int().positive().default(1024), - timeoutMs, - retry, -}); - -export type AnthropicLLMConfig = z.infer; - -/** OpenAI-provider config. `apiKey` is required; `model` defaults to a current GPT. */ -export const OpenAILLMConfigSchema = z.object({ - apiKey: z.string(), - model: z.string().default("gpt-4o"), - baseUrl: z.string().default("https://api.openai.com/v1/chat/completions"), - maxTokens: z.number().int().positive().default(1024), - timeoutMs, - retry, -}); - -export type OpenAILLMConfig = z.infer; - -/** - * LLM config. Replaces the Go `env:`-tagged struct + ozzo `ValidateWithContext`. `echo` - * (default) returns a deterministic response and never hits the network; `noop` returns an - * empty completion; `anthropic` and `openai` call their respective REST APIs and therefore - * require an `apiKey`. Keys are passed via config — the package never reads the environment - * itself, keeping it server-side. - */ -export const LLMConfigSchema = z - .object({ - provider: z.enum(["echo", "noop", "anthropic", "openai"]).default("echo"), - anthropic: AnthropicLLMConfigSchema.optional(), - openai: OpenAILLMConfigSchema.optional(), - }) - .superRefine((cfg, ctx) => { - if (cfg.provider === "anthropic" && cfg.anthropic === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["anthropic"], - message: - "anthropic config (with apiKey) is required when provider is 'anthropic'", - }); - } - if (cfg.provider === "openai" && cfg.openai === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["openai"], - message: "openai config (with apiKey) is required when provider is 'openai'", - }); - } - }); - -export type LLMConfig = z.infer; -export type LLMConfigInput = z.input; diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts deleted file mode 100644 index 23bd352..0000000 --- a/packages/llm/src/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { ObservabilityDeps } from "@primandproper/observability"; - -import { LLMConfigSchema, type LLMConfigInput } from "./config.js"; -import type { LLMProvider } from "./llm.js"; -import { AnthropicLLMProvider } from "./providers/anthropic.node.js"; -import { EchoLLMProvider } from "./providers/echo.js"; -import { NoopLLMProvider } from "./providers/noop.js"; -import { OpenAILLMProvider } from "./providers/openai.node.js"; - -export * from "./llm.js"; -export * from "./config.js"; -export { EchoLLMProvider } from "./providers/echo.js"; -export { NoopLLMProvider } from "./providers/noop.js"; -export { - AnthropicLLMProvider, - type AnthropicLLMProviderOptions, - type FetchLike, -} from "./providers/anthropic.node.js"; -export { - OpenAILLMProvider, - type OpenAILLMProviderOptions, -} from "./providers/openai.node.js"; - -/** - * Validates config and returns the matching {@link LLMProvider}. Mirrors the Go platform's - * `ProvideLLM`. Supports `echo` (default, zero-config), `noop`, `anthropic`, and `openai`. - */ -export function provideLLM( - config?: LLMConfigInput, - deps?: ObservabilityDeps, -): LLMProvider { - const cfg = LLMConfigSchema.parse(config ?? {}); - switch (cfg.provider) { - case "echo": - return new EchoLLMProvider(); - case "noop": - return new NoopLLMProvider(); - case "anthropic": - // superRefine guarantees this, but narrow for the type checker. - if (cfg.anthropic === undefined) { - throw new Error("anthropic config is required when provider is 'anthropic'"); - } - return new AnthropicLLMProvider( - { - apiKey: cfg.anthropic.apiKey, - model: cfg.anthropic.model, - baseUrl: cfg.anthropic.baseUrl, - maxTokens: cfg.anthropic.maxTokens, - timeoutMs: cfg.anthropic.timeoutMs, - retry: cfg.anthropic.retry, - }, - deps, - ); - case "openai": - if (cfg.openai === undefined) { - throw new Error("openai config is required when provider is 'openai'"); - } - return new OpenAILLMProvider( - { - apiKey: cfg.openai.apiKey, - model: cfg.openai.model, - baseUrl: cfg.openai.baseUrl, - maxTokens: cfg.openai.maxTokens, - timeoutMs: cfg.openai.timeoutMs, - retry: cfg.openai.retry, - }, - deps, - ); - } -} diff --git a/packages/llm/src/llm.test.ts b/packages/llm/src/llm.test.ts deleted file mode 100644 index 8dc572e..0000000 --- a/packages/llm/src/llm.test.ts +++ /dev/null @@ -1,671 +0,0 @@ -import { makeRecordingObserver } from "@primandproper/observability"; -import { describe, expect, it, vi } from "vitest"; - -import { AnthropicLLMProvider, type FetchLike } from "./providers/anthropic.node.js"; -import { EchoLLMProvider } from "./providers/echo.js"; -import { NoopLLMProvider } from "./providers/noop.js"; -import { OpenAILLMProvider } from "./providers/openai.node.js"; - -import { provideLLM, type Message } from "./index.js"; - -/** - * Builds a Response-like value with a stubbed `json()` / `text()` for an injected fetch. - * `headers` seeds a `headers.get(name)` so the request-id capture path can be exercised. - */ -function fakeResponse( - ok: boolean, - status: number, - body: unknown, - headers: Record = {}, -): Response { - return { - ok, - status, - headers: { - get: (name: string) => headers[name.toLowerCase()] ?? null, - }, - json: () => Promise.resolve(body), - text: () => Promise.resolve(typeof body === "string" ? body : JSON.stringify(body)), - } as unknown as Response; -} - -/** A meter provider whose counters record every add(), keyed by instrument name. */ -function recordingMetrics(): { - metrics: never; - adds: { name: string; value: number; attrs?: unknown }[]; -} { - const adds: { name: string; value: number; attrs?: unknown }[] = []; - const meter = { - createCounter: (name: string) => ({ - add: (value: number, attrs?: unknown) => adds.push({ name, value, attrs }), - }), - createUpDownCounter: () => ({ add: () => undefined }), - createHistogram: () => ({ record: () => undefined }), - createGauge: () => ({ record: () => undefined }), - }; - return { metrics: { getMeter: () => meter } as unknown as never, adds }; -} - -describe("EchoLLMProvider", () => { - it("echoes the last user message", async () => { - const provider = new EchoLLMProvider(); - const messages: Message[] = [ - { role: "system", content: "be terse" }, - { role: "user", content: "first" }, - { role: "assistant", content: "ok" }, - { role: "user", content: "second" }, - ]; - - const response = await provider.complete({ messages }); - - expect(response.text).toBe("echo: second"); - expect(response.model).toBe("echo"); - }); - - it("pings without throwing", async () => { - await expect(new EchoLLMProvider().ping()).resolves.toBeUndefined(); - }); -}); - -describe("NoopLLMProvider", () => { - it("returns empty text", async () => { - const response = await new NoopLLMProvider().complete(); - expect(response.text).toBe(""); - }); -}); - -describe("REST provider ping validates the key against GET /v1/models", () => { - it("Anthropic ping GETs /v1/models with the key and resolves on 200", async () => { - const fetch = vi - .fn() - .mockResolvedValue(fakeResponse(true, 200, { data: [] })); - const provider = new AnthropicLLMProvider({ - apiKey: "secret-key", - model: "claude-sonnet-4-6", - baseUrl: "https://api.anthropic.com/v1/messages", - maxTokens: 1024, - fetch, - }); - await expect(provider.ping()).resolves.toBeUndefined(); - const [url, init] = fetch.mock.calls[0]!; - expect(url).toBe("https://api.anthropic.com/v1/models"); - expect(init.method).toBe("GET"); - expect((init.headers as Record)["x-api-key"]).toBe("secret-key"); - }); - - it("Anthropic ping rejects on a 401", async () => { - const fetch = vi - .fn() - .mockResolvedValue(fakeResponse(false, 401, "unauthorized")); - const provider = new AnthropicLLMProvider({ - apiKey: "bad-key", - model: "claude-sonnet-4-6", - baseUrl: "https://api.anthropic.com/v1/messages", - maxTokens: 1024, - fetch, - }); - await expect(provider.ping()).rejects.toThrow(/401/); - }); - - it("OpenAI ping GETs /v1/models with a bearer token and resolves on 200", async () => { - const fetch = vi - .fn() - .mockResolvedValue(fakeResponse(true, 200, { data: [] })); - const provider = new OpenAILLMProvider({ - apiKey: "secret-key", - model: "gpt-4o", - baseUrl: "https://api.openai.com/v1/chat/completions", - maxTokens: 1024, - fetch, - }); - await expect(provider.ping()).resolves.toBeUndefined(); - const [url, init] = fetch.mock.calls[0]!; - expect(url).toBe("https://api.openai.com/v1/models"); - expect(init.method).toBe("GET"); - expect((init.headers as Record).authorization).toBe( - "Bearer secret-key", - ); - }); - - it("OpenAI ping rejects on a 401", async () => { - const fetch = vi - .fn() - .mockResolvedValue(fakeResponse(false, 401, "unauthorized")); - const provider = new OpenAILLMProvider({ - apiKey: "bad-key", - model: "gpt-4o", - baseUrl: "https://api.openai.com/v1/chat/completions", - maxTokens: 1024, - fetch, - }); - await expect(provider.ping()).rejects.toThrow(/401/); - }); -}); - -describe("AnthropicLLMProvider", () => { - it("POSTs the right URL and headers, folds system, and concatenates content blocks", async () => { - const fetch = vi.fn().mockResolvedValue( - fakeResponse(true, 200, { - content: [ - { type: "text", text: "Hello " }, - { type: "text", text: "world" }, - ], - model: "claude-sonnet-4-6", - stop_reason: "end_turn", - usage: { input_tokens: 10, output_tokens: 5 }, - }), - ); - - const provider = new AnthropicLLMProvider({ - apiKey: "secret-key", - model: "claude-sonnet-4-6", - baseUrl: "https://api.anthropic.com/v1/messages", - maxTokens: 1024, - fetch, - }); - - const response = await provider.complete({ - messages: [ - { role: "system", content: "you are helpful" }, - { role: "user", content: "hi" }, - ], - system: "top-level system", - }); - - expect(response.text).toBe("Hello world"); - expect(response.model).toBe("claude-sonnet-4-6"); - expect(response.stopReason).toBe("end_turn"); - expect(response.usage).toEqual({ inputTokens: 10, outputTokens: 5 }); - - expect(fetch).toHaveBeenCalledTimes(1); - const [url, init] = fetch.mock.calls[0]!; - expect(url).toBe("https://api.anthropic.com/v1/messages"); - expect(init.method).toBe("POST"); - expect(init.headers).toMatchObject({ - "x-api-key": "secret-key", - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }); - - const body = JSON.parse(init.body as string) as { - max_tokens: number; - system: string; - messages: Message[]; - }; - expect(body.max_tokens).toBe(1024); - // The system Message is folded into the top-level `system` field, not sent as a role. - expect(body.system).toBe("top-level system\n\nyou are helpful"); - expect(body.messages).toEqual([{ role: "user", content: "hi" }]); - expect(body.messages.some((m) => m.role === "system")).toBe(false); - }); - - it("throws on a non-ok response", async () => { - const fetch = vi - .fn() - .mockResolvedValue(fakeResponse(false, 401, "unauthorized")); - - const provider = new AnthropicLLMProvider({ - apiKey: "bad", - model: "claude-sonnet-4-6", - baseUrl: "https://api.anthropic.com/v1/messages", - maxTokens: 1024, - fetch, - }); - - await expect( - provider.complete({ messages: [{ role: "user", content: "hi" }] }), - ).rejects.toThrow(/401/); - }); - - it("records token counters and the model on the span for a successful completion", async () => { - const fetch = vi.fn().mockResolvedValue( - fakeResponse(true, 200, { - content: [{ type: "text", text: "hi" }], - model: "claude-sonnet-4-6", - usage: { input_tokens: 10, output_tokens: 5 }, - }), - ); - const observer = makeRecordingObserver(); - const { metrics, adds } = recordingMetrics(); - - const provider = new AnthropicLLMProvider( - { - apiKey: "k", - model: "claude-sonnet-4-6", - baseUrl: "https://api.anthropic.com/v1/messages", - maxTokens: 1024, - fetch, - }, - { observer, metrics }, - ); - - await provider.complete({ messages: [{ role: "user", content: "hi" }] }); - - expect(adds).toContainEqual({ - name: "llm.tokens.input", - value: 10, - attrs: { model: "claude-sonnet-4-6" }, - }); - expect(adds).toContainEqual({ - name: "llm.tokens.output", - value: 5, - attrs: { model: "claude-sonnet-4-6" }, - }); - expect(observer.data()).toMatchObject({ - model: "claude-sonnet-4-6", - "llm.tokens.input": 10, - "llm.tokens.output": 5, - }); - }); - - it("captures the vendor request id on an error", async () => { - const fetch = vi - .fn() - .mockResolvedValue( - fakeResponse(false, 401, "unauthorized", { "request-id": "req_abc123" }), - ); - const observer = makeRecordingObserver(); - - const provider = new AnthropicLLMProvider( - { - apiKey: "bad", - model: "claude-sonnet-4-6", - baseUrl: "https://api.anthropic.com/v1/messages", - maxTokens: 1024, - fetch, - }, - { observer }, - ); - - await expect( - provider.complete({ messages: [{ role: "user", content: "hi" }] }), - ).rejects.toThrow(/401/); - - expect(observer.data().requestId).toBe("req_abc123"); - expect(observer.errors).toHaveLength(1); - }); -}); - -describe("OpenAILLMProvider", () => { - it("sends system as a message and maps choices[0]", async () => { - const fetch = vi.fn().mockResolvedValue( - fakeResponse(true, 200, { - choices: [{ message: { content: "Hi there" }, finish_reason: "stop" }], - model: "gpt-4o", - usage: { prompt_tokens: 7, completion_tokens: 3 }, - }), - ); - - const provider = new OpenAILLMProvider({ - apiKey: "sk-test", - model: "gpt-4o", - baseUrl: "https://api.openai.com/v1/chat/completions", - maxTokens: 1024, - fetch, - }); - - const response = await provider.complete({ - messages: [{ role: "user", content: "hi" }], - system: "be brief", - }); - - expect(response.text).toBe("Hi there"); - expect(response.model).toBe("gpt-4o"); - expect(response.stopReason).toBe("stop"); - expect(response.usage).toEqual({ inputTokens: 7, outputTokens: 3 }); - - expect(fetch).toHaveBeenCalledTimes(1); - const [url, init] = fetch.mock.calls[0]!; - expect(url).toBe("https://api.openai.com/v1/chat/completions"); - expect(init.headers).toMatchObject({ - authorization: "Bearer sk-test", - "content-type": "application/json", - }); - - const body = JSON.parse(init.body as string) as { - max_tokens: number; - messages: Message[]; - }; - expect(body.max_tokens).toBe(1024); - // OpenAI accepts a system role: the top-level system is prepended as a message. - expect(body.messages).toEqual([ - { role: "system", content: "be brief" }, - { role: "user", content: "hi" }, - ]); - }); - - it("throws on a non-ok response", async () => { - const fetch = vi - .fn() - .mockResolvedValue(fakeResponse(false, 500, "server error")); - - const provider = new OpenAILLMProvider({ - apiKey: "sk-test", - model: "gpt-4o", - baseUrl: "https://api.openai.com/v1/chat/completions", - maxTokens: 1024, - fetch, - }); - - await expect( - provider.complete({ messages: [{ role: "user", content: "hi" }] }), - ).rejects.toThrow(/500/); - }); - - it("records token counters and the model on the span for a successful completion", async () => { - const fetch = vi.fn().mockResolvedValue( - fakeResponse(true, 200, { - choices: [{ message: { content: "Hi there" }, finish_reason: "stop" }], - model: "gpt-4o", - usage: { prompt_tokens: 7, completion_tokens: 3 }, - }), - ); - const observer = makeRecordingObserver(); - const { metrics, adds } = recordingMetrics(); - - const provider = new OpenAILLMProvider( - { - apiKey: "sk-test", - model: "gpt-4o", - baseUrl: "https://api.openai.com/v1/chat/completions", - maxTokens: 1024, - fetch, - }, - { observer, metrics }, - ); - - await provider.complete({ messages: [{ role: "user", content: "hi" }] }); - - expect(adds).toContainEqual({ - name: "llm.tokens.input", - value: 7, - attrs: { model: "gpt-4o" }, - }); - expect(adds).toContainEqual({ - name: "llm.tokens.output", - value: 3, - attrs: { model: "gpt-4o" }, - }); - expect(observer.data()).toMatchObject({ - model: "gpt-4o", - "llm.tokens.input": 7, - "llm.tokens.output": 3, - }); - }); - - it("captures the vendor request id on an error", async () => { - const fetch = vi - .fn() - .mockResolvedValue( - fakeResponse(false, 500, "server error", { "x-request-id": "req_openai_9" }), - ); - const observer = makeRecordingObserver(); - - const provider = new OpenAILLMProvider( - { - apiKey: "sk-test", - model: "gpt-4o", - baseUrl: "https://api.openai.com/v1/chat/completions", - maxTokens: 1024, - fetch, - }, - { observer }, - ); - - await expect( - provider.complete({ messages: [{ role: "user", content: "hi" }] }), - ).rejects.toThrow(/500/); - - expect(observer.data().requestId).toBe("req_openai_9"); - expect(observer.errors).toHaveLength(1); - }); -}); - -describe("LLM resilience (timeout + retry)", () => { - const base = { - apiKey: "k", - model: "claude-sonnet-4-6", - baseUrl: "https://api.anthropic.com/v1/messages", - maxTokens: 1024, - }; - const ask = { messages: [{ role: "user" as const, content: "hi" }] }; - - it("passes an abort signal into the completion fetch by default", async () => { - const fetch = vi - .fn() - .mockResolvedValue(fakeResponse(true, 200, { content: [], model: "m" })); - const provider = new AnthropicLLMProvider({ ...base, fetch }); - - await provider.complete(ask); - - expect(fetch.mock.calls[0]?.[1].signal).toBeInstanceOf(AbortSignal); - }); - - it("sends no signal when the timeout is disabled", async () => { - const fetch = vi - .fn() - .mockResolvedValue(fakeResponse(true, 200, { content: [], model: "m" })); - const provider = new AnthropicLLMProvider({ ...base, timeoutMs: 0, fetch }); - - await provider.complete(ask); - - expect(fetch.mock.calls[0]?.[1].signal ?? undefined).toBeUndefined(); - }); - - it("does not retry a 529 overload unless a retry policy is configured", async () => { - const fetch = vi - .fn() - .mockResolvedValue(fakeResponse(false, 529, "overloaded")); - const provider = new AnthropicLLMProvider({ ...base, fetch }); - - await expect(provider.complete(ask)).rejects.toThrow(/529/); - expect(fetch).toHaveBeenCalledOnce(); - }); - - it("retries a 529 overload when a retry policy is configured, then succeeds", async () => { - const fetch = vi - .fn() - .mockResolvedValueOnce(fakeResponse(false, 529, "overloaded")) - .mockResolvedValueOnce(fakeResponse(true, 200, { content: [], model: "m" })); - const provider = new AnthropicLLMProvider({ - ...base, - retry: { - maxAttempts: 3, - baseDelayMs: 0, - maxDelayMs: 0, - jitter: 0, - maxElapsedMs: 0, - }, - fetch, - }); - - await provider.complete(ask); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("does not retry a 400 even with a retry policy configured", async () => { - const fetch = vi - .fn() - .mockResolvedValue(fakeResponse(false, 400, "bad request")); - const provider = new OpenAILLMProvider({ - apiKey: "k", - model: "gpt-4o", - baseUrl: "https://api.openai.com/v1/chat/completions", - maxTokens: 1024, - retry: { - maxAttempts: 3, - baseDelayMs: 0, - maxDelayMs: 0, - jitter: 0, - maxElapsedMs: 0, - }, - fetch, - }); - - await expect(provider.complete(ask)).rejects.toThrow(/400/); - expect(fetch).toHaveBeenCalledOnce(); - }); -}); - -/** Parses the JSON request body a mocked fetch was called with, typed for the fields we assert. */ -function sentBody(fetch: ReturnType>): { - stream?: boolean; - stream_options?: unknown; -} { - const body = fetch.mock.calls[0]?.[1].body; - return JSON.parse(typeof body === "string" ? body : "{}") as { - stream?: boolean; - stream_options?: unknown; - }; -} - -/** Builds a real streaming `Response` (a `ReadableStream` body) from SSE text. */ -function sseResponse(body: string, status = 200): Response { - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(body)); - controller.close(); - }, - }); - return new Response(stream, { - status, - headers: { "content-type": "text/event-stream" }, - }); -} - -const anthropicSse = [ - "event: message_start", - 'data: {"type":"message_start","message":{"usage":{"input_tokens":7}}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello "}}', - "", - "event: content_block_delta", - 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"world"}}', - "", - "event: message_delta", - 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}', - "", - "event: message_stop", - 'data: {"type":"message_stop"}', - "", -].join("\n"); - -const openaiSse = [ - 'data: {"choices":[{"delta":{"content":"Hello "},"finish_reason":null}]}', - "", - 'data: {"choices":[{"delta":{"content":"world"},"finish_reason":null}]}', - "", - 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}', - "", - 'data: {"choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3}}', - "", - "data: [DONE]", - "", -].join("\n"); - -describe("LLM streaming (completeStream)", () => { - const ask = { messages: [{ role: "user" as const, content: "hi" }] }; - - it("echoes as a single delta then a terminal usage chunk", async () => { - const chunks = []; - for await (const chunk of new EchoLLMProvider().completeStream({ - messages: [{ role: "user", content: "hey" }], - })) { - chunks.push(chunk); - } - expect(chunks.map((c) => c.delta).join("")).toBe("echo: hey"); - expect(chunks.at(-1)?.usage).toBeDefined(); - }); - - it("parses anthropic SSE deltas, stop reason, and usage", async () => { - const fetch = vi.fn().mockResolvedValue(sseResponse(anthropicSse)); - const provider = new AnthropicLLMProvider({ - apiKey: "k", - model: "claude-sonnet-4-6", - baseUrl: "https://api.anthropic.com/v1/messages", - maxTokens: 1024, - fetch, - }); - - const chunks = []; - for await (const chunk of provider.completeStream(ask)) { - chunks.push(chunk); - } - - expect(chunks.map((c) => c.delta).join("")).toBe("Hello world"); - expect(chunks.at(-1)?.stopReason).toBe("end_turn"); - expect(chunks.at(-1)?.usage).toStrictEqual({ inputTokens: 7, outputTokens: 3 }); - // The request asked the API to stream. - expect(sentBody(fetch).stream).toBe(true); - }); - - it("parses openai SSE deltas, finish reason, and usage", async () => { - const fetch = vi.fn().mockResolvedValue(sseResponse(openaiSse)); - const provider = new OpenAILLMProvider({ - apiKey: "k", - model: "gpt-4o", - baseUrl: "https://api.openai.com/v1/chat/completions", - maxTokens: 1024, - fetch, - }); - - const chunks = []; - for await (const chunk of provider.completeStream(ask)) { - chunks.push(chunk); - } - - expect(chunks.map((c) => c.delta).join("")).toBe("Hello world"); - expect(chunks.at(-1)?.stopReason).toBe("stop"); - expect(chunks.at(-1)?.usage).toStrictEqual({ inputTokens: 7, outputTokens: 3 }); - const sent = sentBody(fetch); - expect(sent.stream).toBe(true); - expect(sent.stream_options).toStrictEqual({ include_usage: true }); - }); - - it("throws on a non-2xx streaming response", async () => { - const fetch = vi - .fn() - .mockResolvedValue(new Response("nope", { status: 500 })); - const provider = new AnthropicLLMProvider({ - apiKey: "k", - model: "claude-sonnet-4-6", - baseUrl: "https://api.anthropic.com/v1/messages", - maxTokens: 1024, - fetch, - }); - - await expect(async () => { - for await (const chunk of provider.completeStream(ask)) { - void chunk; // drain - } - }).rejects.toThrow(/500/); - }); -}); - -describe("provideLLM", () => { - it("defaults to the echo provider", async () => { - const provider = provideLLM(); - expect(provider).toBeInstanceOf(EchoLLMProvider); - const response = await provider.complete({ - messages: [{ role: "user", content: "ping" }], - }); - expect(response.text).toBe("echo: ping"); - }); - - it("builds an anthropic provider from config", () => { - const provider = provideLLM({ - provider: "anthropic", - anthropic: { apiKey: "k" }, - }); - expect(provider).toBeInstanceOf(AnthropicLLMProvider); - }); - - it("rejects an anthropic provider without config", () => { - expect(() => provideLLM({ provider: "anthropic" })).toThrow(); - }); - - it("rejects an openai provider without config", () => { - expect(() => provideLLM({ provider: "openai" })).toThrow(); - }); -}); diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts deleted file mode 100644 index 0c8e4cb..0000000 --- a/packages/llm/src/llm.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** The role of a message in a chat completion. */ -export type Role = "system" | "user" | "assistant"; - -/** A single chat message. */ -export interface Message { - role: Role; - content: string; -} - -/** - * A chat-completion request. `messages` is the conversation; the optional fields tune the - * call. `system` is a top-level instruction kept separate from `messages` so providers that - * model it distinctly (Anthropic) and providers that fold it into the message list (OpenAI) - * share one call-site shape. - */ -export interface CompletionRequest { - messages: Message[]; - model?: string; - maxTokens?: number; - temperature?: number; - system?: string; - /** - * Cancels the request. For {@link LLMProvider.complete} it combines with the provider's - * timeout; for {@link LLMProvider.completeStream} it is the only cancellation signal (a - * streaming generation legitimately outlives any fixed timeout, so no deadline is imposed). - */ - signal?: AbortSignal; -} - -/** Token usage reported by the provider, when available. */ -export interface Usage { - inputTokens: number; - outputTokens: number; -} - -/** A chat-completion response. */ -export interface CompletionResponse { - text: string; - model: string; - stopReason?: string; - usage?: Usage; -} - -/** - * One incremental event from {@link LLMProvider.completeStream}. `delta` is the text produced by - * this event (possibly empty on a non-text event); the final event carries `stopReason`/`usage` - * when the vendor reports them, so a consumer can accumulate `delta`s and read the tail metadata. - */ -export interface CompletionChunk { - delta: string; - stopReason?: string; - usage?: Usage; -} - -/** - * The chat-completion contract. Provider implementations live under `providers/` and are - * selected by config — the same shape backs the deterministic `echo`/`noop` providers and - * the REST providers (Anthropic, OpenAI). - */ -export interface LLMProvider { - /** Generates a completion for the conversation. */ - complete(request: CompletionRequest): Promise; - /** - * Streams a completion as incremental {@link CompletionChunk}s. The returned async iterable - * yields text deltas as the vendor produces them, so a long generation is surfaced token-by-token - * instead of buffered whole (which is likeliest to hit intermediary timeouts). Iterate to - * completion, or stop early / abort via `request.signal` to cancel the underlying connection. - */ - completeStream(request: CompletionRequest): AsyncIterable; - /** Verifies the backing provider is reachable. */ - ping(): Promise; -} - -/** Returns the content of the last `user` message, or `""` when there is none. */ -export function lastUserMessage(messages: Message[]): string { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (message?.role === "user") { - return message.content; - } - } - return ""; -} diff --git a/packages/llm/src/providers/anthropic.node.ts b/packages/llm/src/providers/anthropic.node.ts deleted file mode 100644 index 90ad16a..0000000 --- a/packages/llm/src/providers/anthropic.node.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import type { Policy, RetryConfig } from "@primandproper/retry"; - -import type { - CompletionChunk, - CompletionRequest, - CompletionResponse, - LLMProvider, - Message, - Usage, -} from "../llm.js"; - -import { - DEFAULT_LLM_TIMEOUT_MS, - requestIdFromResponse, - resilientFetch, - retryPolicy, - sseDataLines, - tokenInstruments, - type TokenInstruments, -} from "./support.js"; - -const o11yName = "llm"; - -/** The slice of `fetch` the provider relies on. Injectable so tests need no network. */ -export type FetchLike = (input: string, init: RequestInit) => Promise; - -/** Construction options for {@link AnthropicLLMProvider}. */ -export interface AnthropicLLMProviderOptions { - /** The Anthropic API key. Read from config; never hardcoded or read from the environment. */ - apiKey: string; - /** The default model when a request omits one. */ - model: string; - /** The Messages API endpoint. */ - baseUrl: string; - /** The default `max_tokens` when a request omits one. Anthropic requires this field. */ - maxTokens: number; - /** Per-completion deadline in milliseconds; `0` disables it. Defaults to 30s. */ - timeoutMs?: number; - /** Optional retry policy for transient failures (network/timeout, 429/5xx). Off by default. */ - retry?: RetryConfig | undefined; - /** The `fetch` implementation. Defaults to `globalThis.fetch`. */ - fetch?: FetchLike; -} - -const ANTHROPIC_VERSION = "2023-06-01"; - -interface AnthropicContentBlock { - type: string; - text?: string; -} - -interface AnthropicResponseBody { - content?: AnthropicContentBlock[]; - model?: string; - stop_reason?: string; - usage?: { input_tokens?: number; output_tokens?: number }; -} - -/** - * A zero-dependency {@link LLMProvider} over the Anthropic Messages REST API, built on the - * global `fetch`. Anthropic requires `max_tokens` and does not accept a `system` role inside - * `messages` — any `system` message (and the request's top-level `system`) is folded into the - * top-level `system` field, leaving only `user`/`assistant` roles in `messages`. - */ -export class AnthropicLLMProvider implements LLMProvider { - readonly #apiKey: string; - readonly #model: string; - readonly #baseUrl: string; - readonly #maxTokens: number; - readonly #fetch: FetchLike; - readonly #timeoutMs: number; - readonly #retry: Policy | undefined; - readonly #observer: Observer; - readonly #tokens: TokenInstruments; - - constructor(options: AnthropicLLMProviderOptions, deps: ObservabilityDeps = {}) { - this.#apiKey = options.apiKey; - this.#model = options.model; - this.#baseUrl = options.baseUrl; - this.#maxTokens = options.maxTokens; - this.#fetch = resolveFetch(options.fetch); - this.#timeoutMs = options.timeoutMs ?? DEFAULT_LLM_TIMEOUT_MS; - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#retry = retryPolicy(options.retry, this.#observer.logger()); - this.#tokens = tokenInstruments(o11yName, deps); - } - - complete(request: CompletionRequest): Promise { - return this.#observer.run("complete", async (op) => { - const model = request.model ?? this.#model; - op.set("model", model); - const { messages, system } = splitSystem(request.messages, request.system); - - const body: { - model: string; - max_tokens: number; - messages: Message[]; - system?: string; - temperature?: number; - } = { - model, - max_tokens: request.maxTokens ?? this.#maxTokens, - messages, - }; - if (system !== "") { - body.system = system; - } - if (request.temperature !== undefined) { - body.temperature = request.temperature; - } - - const response = await resilientFetch( - (signal) => - this.#fetch(this.#baseUrl, { - method: "POST", - headers: { - "x-api-key": this.#apiKey, - "anthropic-version": ANTHROPIC_VERSION, - "content-type": "application/json", - }, - body: JSON.stringify(body), - ...(signal === undefined ? {} : { signal }), - }), - { timeoutMs: this.#timeoutMs, retry: this.#retry, signal: request.signal }, - ); - - if (!response.ok) { - const text = await response.text(); - const requestId = requestIdFromResponse(response, "request-id", "x-request-id"); - if (requestId !== undefined) { - op.set("requestId", requestId); - } - throw op.error( - new Error(`anthropic request failed: ${String(response.status)} ${text}`), - `anthropic completion failed with status ${String(response.status)}`, - ); - } - - const data = (await response.json()) as AnthropicResponseBody; - const text = (data.content ?? []) - .filter((block) => block.type === "text" && block.text !== undefined) - .map((block) => block.text ?? "") - .join(""); - - const result: CompletionResponse = { text, model: data.model ?? model }; - if (data.stop_reason !== undefined) { - result.stopReason = data.stop_reason; - } - if (data.usage !== undefined) { - const usage = { - inputTokens: data.usage.input_tokens ?? 0, - outputTokens: data.usage.output_tokens ?? 0, - }; - result.usage = usage; - op.set("llm.tokens.input", usage.inputTokens).set( - "llm.tokens.output", - usage.outputTokens, - ); - this.#tokens.inputTokens.add(usage.inputTokens, { model }); - this.#tokens.outputTokens.add(usage.outputTokens, { model }); - } - return result; - }); - } - - async *completeStream(request: CompletionRequest): AsyncGenerator { - const model = request.model ?? this.#model; - const { messages, system } = splitSystem(request.messages, request.system); - - const body: { - model: string; - max_tokens: number; - messages: Message[]; - stream: true; - system?: string; - temperature?: number; - } = { - model, - max_tokens: request.maxTokens ?? this.#maxTokens, - messages, - stream: true, - }; - if (system !== "") { - body.system = system; - } - if (request.temperature !== undefined) { - body.temperature = request.temperature; - } - - // Streaming imposes no timeout — a long generation legitimately outlives one; only the - // caller's signal cancels it. Retry is likewise inapplicable mid-stream (no resume point). - this.#observer.logger().debug("streaming completion", { model }); - const response = await this.#fetch(this.#baseUrl, { - method: "POST", - headers: { - "x-api-key": this.#apiKey, - "anthropic-version": ANTHROPIC_VERSION, - "content-type": "application/json", - }, - body: JSON.stringify(body), - ...(request.signal === undefined ? {} : { signal: request.signal }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`anthropic stream failed: ${String(response.status)} ${text}`); - } - - let inputTokens = 0; - let outputTokens = 0; - let stopReason: string | undefined; - for await (const data of sseDataLines(response.body)) { - const event = JSON.parse(data) as AnthropicStreamEvent; - switch (event.type) { - case "message_start": - inputTokens = event.message?.usage?.input_tokens ?? inputTokens; - break; - case "content_block_delta": - if (event.delta?.type === "text_delta") { - yield { delta: event.delta.text ?? "" }; - } - break; - case "message_delta": - stopReason = event.delta?.stop_reason ?? stopReason; - outputTokens = event.usage?.output_tokens ?? outputTokens; - break; - default: - break; - } - } - - this.#tokens.inputTokens.add(inputTokens, { model }); - this.#tokens.outputTokens.add(outputTokens, { model }); - const usage: Usage = { inputTokens, outputTokens }; - yield stopReason === undefined - ? { delta: "", usage } - : { delta: "", stopReason, usage }; - } - - /** - * Validates reachability and the API key against Anthropic's free, authenticated - * `GET /v1/models` endpoint (no tokens billed). Rejects when the key is invalid or the API is - * unreachable, so a caller can fail fast at startup instead of on the first real completion. - */ - ping(): Promise { - return this.#observer.run("ping", async (op) => { - const url = new URL("/v1/models", this.#baseUrl).toString(); - const response = await resilientFetch( - (signal) => - this.#fetch(url, { - method: "GET", - headers: { - "x-api-key": this.#apiKey, - "anthropic-version": ANTHROPIC_VERSION, - }, - ...(signal === undefined ? {} : { signal }), - }), - { timeoutMs: this.#timeoutMs, retry: this.#retry }, - ); - if (!response.ok) { - const text = await response.text(); - throw op.error( - new Error(`anthropic ping failed: ${String(response.status)} ${text}`), - `anthropic ping failed with status ${String(response.status)}`, - ); - } - }); - } -} - -/** The slice of Anthropic's `message_*`/`content_block_delta` stream events this provider reads. */ -interface AnthropicStreamEvent { - type?: string; - delta?: { type?: string; text?: string; stop_reason?: string }; - message?: { usage?: { input_tokens?: number } }; - usage?: { output_tokens?: number }; -} - -/** - * Splits a message list into Anthropic's shape: `system` messages and the request's top-level - * `system` are concatenated into a single top-level system string, and only `user`/`assistant` - * roles remain in `messages`. - */ -function splitSystem( - messages: Message[], - topLevelSystem: string | undefined, -): { messages: Message[]; system: string } { - const systemParts: string[] = []; - if (topLevelSystem !== undefined && topLevelSystem !== "") { - systemParts.push(topLevelSystem); - } - - const rest: Message[] = []; - for (const message of messages) { - if (message.role === "system") { - systemParts.push(message.content); - } else { - rest.push(message); - } - } - - return { messages: rest, system: systemParts.join("\n\n") }; -} - -function resolveFetch(fetch?: FetchLike): FetchLike { - if (fetch !== undefined) { - return fetch; - } - if (typeof globalThis.fetch === "function") { - return globalThis.fetch.bind(globalThis); - } - throw new Error("no fetch implementation available; pass one via options.fetch"); -} diff --git a/packages/llm/src/providers/echo.ts b/packages/llm/src/providers/echo.ts deleted file mode 100644 index c85535f..0000000 --- a/packages/llm/src/providers/echo.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - lastUserMessage, - type CompletionChunk, - type CompletionRequest, - type CompletionResponse, - type LLMProvider, -} from "../llm.js"; - -/** - * A deterministic {@link LLMProvider} for tests and local development. It echoes the last - * user message back as `echo: ` and never touches the network. - */ -export class EchoLLMProvider implements LLMProvider { - complete(request: CompletionRequest): Promise { - return Promise.resolve({ - text: `echo: ${lastUserMessage(request.messages)}`, - model: "echo", - }); - } - - /** Streams the same echo as a single delta, then a terminal usage chunk. */ - async *completeStream(request: CompletionRequest): AsyncGenerator { - yield { delta: `echo: ${lastUserMessage(request.messages)}` }; - yield { delta: "", stopReason: "stop", usage: { inputTokens: 0, outputTokens: 0 } }; - } - - ping(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/llm/src/providers/noop.ts b/packages/llm/src/providers/noop.ts deleted file mode 100644 index 97cc7f6..0000000 --- a/packages/llm/src/providers/noop.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { CompletionChunk, CompletionResponse, LLMProvider } from "../llm.js"; - -/** An {@link LLMProvider} that returns an empty-text completion for every request. */ -export class NoopLLMProvider implements LLMProvider { - complete(): Promise { - return Promise.resolve({ text: "", model: "noop" }); - } - - /** Streams nothing but a terminal empty chunk, mirroring the empty {@link complete} result. */ - async *completeStream(): AsyncGenerator { - yield { delta: "", usage: { inputTokens: 0, outputTokens: 0 } }; - } - - ping(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/llm/src/providers/openai.node.ts b/packages/llm/src/providers/openai.node.ts deleted file mode 100644 index 95c5090..0000000 --- a/packages/llm/src/providers/openai.node.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import type { Policy, RetryConfig } from "@primandproper/retry"; - -import type { - CompletionChunk, - CompletionRequest, - CompletionResponse, - LLMProvider, - Message, - Usage, -} from "../llm.js"; - -import type { FetchLike } from "./anthropic.node.js"; -import { - DEFAULT_LLM_TIMEOUT_MS, - requestIdFromResponse, - resilientFetch, - retryPolicy, - sseDataLines, - tokenInstruments, - type TokenInstruments, -} from "./support.js"; - -const o11yName = "llm"; - -/** Construction options for {@link OpenAILLMProvider}. */ -export interface OpenAILLMProviderOptions { - /** The OpenAI API key. Read from config; never hardcoded or read from the environment. */ - apiKey: string; - /** The default model when a request omits one. */ - model: string; - /** The Chat Completions endpoint. */ - baseUrl: string; - /** The default `max_tokens` when a request omits one. */ - maxTokens: number; - /** Per-completion deadline in milliseconds; `0` disables it. Defaults to 30s. */ - timeoutMs?: number; - /** Optional retry policy for transient failures (network/timeout, 429/5xx). Off by default. */ - retry?: RetryConfig | undefined; - /** The `fetch` implementation. Defaults to `globalThis.fetch`. */ - fetch?: FetchLike; -} - -interface OpenAIChoice { - message?: { content?: string }; - finish_reason?: string; -} - -interface OpenAIResponseBody { - choices?: OpenAIChoice[]; - model?: string; - usage?: { prompt_tokens?: number; completion_tokens?: number }; -} - -/** - * A zero-dependency {@link LLMProvider} over the OpenAI Chat Completions REST API, built on - * the global `fetch`. OpenAI accepts a `system` role inside `messages`, so the request's - * top-level `system` is prepended as a `{ role: "system" }` message. - */ -export class OpenAILLMProvider implements LLMProvider { - readonly #apiKey: string; - readonly #model: string; - readonly #baseUrl: string; - readonly #maxTokens: number; - readonly #fetch: FetchLike; - readonly #timeoutMs: number; - readonly #retry: Policy | undefined; - readonly #observer: Observer; - readonly #tokens: TokenInstruments; - - constructor(options: OpenAILLMProviderOptions, deps: ObservabilityDeps = {}) { - this.#apiKey = options.apiKey; - this.#model = options.model; - this.#baseUrl = options.baseUrl; - this.#maxTokens = options.maxTokens; - this.#fetch = resolveFetch(options.fetch); - this.#timeoutMs = options.timeoutMs ?? DEFAULT_LLM_TIMEOUT_MS; - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#retry = retryPolicy(options.retry, this.#observer.logger()); - this.#tokens = tokenInstruments(o11yName, deps); - } - - complete(request: CompletionRequest): Promise { - return this.#observer.run("complete", async (op) => { - const model = request.model ?? this.#model; - op.set("model", model); - - const messages: Message[] = - request.system !== undefined && request.system !== "" - ? [{ role: "system", content: request.system }, ...request.messages] - : request.messages; - - const body: { - model: string; - messages: Message[]; - max_tokens?: number; - temperature?: number; - } = { model, messages }; - const maxTokens = request.maxTokens ?? this.#maxTokens; - if (maxTokens > 0) { - body.max_tokens = maxTokens; - } - if (request.temperature !== undefined) { - body.temperature = request.temperature; - } - - const response = await resilientFetch( - (signal) => - this.#fetch(this.#baseUrl, { - method: "POST", - headers: { - authorization: `Bearer ${this.#apiKey}`, - "content-type": "application/json", - }, - body: JSON.stringify(body), - ...(signal === undefined ? {} : { signal }), - }), - { timeoutMs: this.#timeoutMs, retry: this.#retry, signal: request.signal }, - ); - - if (!response.ok) { - const text = await response.text(); - const requestId = requestIdFromResponse(response, "x-request-id", "request-id"); - if (requestId !== undefined) { - op.set("requestId", requestId); - } - throw op.error( - new Error(`openai request failed: ${String(response.status)} ${text}`), - `openai completion failed with status ${String(response.status)}`, - ); - } - - const data = (await response.json()) as OpenAIResponseBody; - const choice = (data.choices ?? [])[0]; - - const result: CompletionResponse = { - text: choice?.message?.content ?? "", - model: data.model ?? model, - }; - if (choice?.finish_reason !== undefined) { - result.stopReason = choice.finish_reason; - } - if (data.usage !== undefined) { - const usage = { - inputTokens: data.usage.prompt_tokens ?? 0, - outputTokens: data.usage.completion_tokens ?? 0, - }; - result.usage = usage; - op.set("llm.tokens.input", usage.inputTokens).set( - "llm.tokens.output", - usage.outputTokens, - ); - this.#tokens.inputTokens.add(usage.inputTokens, { model }); - this.#tokens.outputTokens.add(usage.outputTokens, { model }); - } - return result; - }); - } - - async *completeStream(request: CompletionRequest): AsyncGenerator { - const model = request.model ?? this.#model; - const messages: Message[] = - request.system !== undefined && request.system !== "" - ? [{ role: "system", content: request.system }, ...request.messages] - : request.messages; - - const body: { - model: string; - messages: Message[]; - stream: true; - stream_options: { include_usage: true }; - max_tokens?: number; - temperature?: number; - } = { - model, - messages, - stream: true, - // Ask OpenAI to emit a final usage-only chunk; without this, streamed responses omit usage. - stream_options: { include_usage: true }, - }; - const maxTokens = request.maxTokens ?? this.#maxTokens; - if (maxTokens > 0) { - body.max_tokens = maxTokens; - } - if (request.temperature !== undefined) { - body.temperature = request.temperature; - } - - // Streaming imposes no timeout — a long generation legitimately outlives one; only the - // caller's signal cancels it. Retry is likewise inapplicable mid-stream (no resume point). - this.#observer.logger().debug("streaming completion", { model }); - const response = await this.#fetch(this.#baseUrl, { - method: "POST", - headers: { - authorization: `Bearer ${this.#apiKey}`, - "content-type": "application/json", - }, - body: JSON.stringify(body), - ...(request.signal === undefined ? {} : { signal: request.signal }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`openai stream failed: ${String(response.status)} ${text}`); - } - - let inputTokens = 0; - let outputTokens = 0; - let stopReason: string | undefined; - for await (const data of sseDataLines(response.body)) { - const event = JSON.parse(data) as OpenAIStreamEvent; - const choice = event.choices?.[0]; - const content = choice?.delta?.content; - if (content !== undefined && content !== "") { - yield { delta: content }; - } - if (choice?.finish_reason !== undefined && choice.finish_reason !== null) { - stopReason = choice.finish_reason; - } - if (event.usage !== undefined) { - inputTokens = event.usage.prompt_tokens ?? inputTokens; - outputTokens = event.usage.completion_tokens ?? outputTokens; - } - } - - this.#tokens.inputTokens.add(inputTokens, { model }); - this.#tokens.outputTokens.add(outputTokens, { model }); - const usage: Usage = { inputTokens, outputTokens }; - yield stopReason === undefined - ? { delta: "", usage } - : { delta: "", stopReason, usage }; - } - - /** - * Validates reachability and the API key against OpenAI's free, authenticated - * `GET /v1/models` endpoint (no tokens billed). Rejects when the key is invalid or the API is - * unreachable, so a caller can fail fast at startup instead of on the first real completion. - */ - ping(): Promise { - return this.#observer.run("ping", async (op) => { - const url = new URL("/v1/models", this.#baseUrl).toString(); - const response = await resilientFetch( - (signal) => - this.#fetch(url, { - method: "GET", - headers: { authorization: `Bearer ${this.#apiKey}` }, - ...(signal === undefined ? {} : { signal }), - }), - { timeoutMs: this.#timeoutMs, retry: this.#retry }, - ); - if (!response.ok) { - const text = await response.text(); - throw op.error( - new Error(`openai ping failed: ${String(response.status)} ${text}`), - `openai ping failed with status ${String(response.status)}`, - ); - } - }); - } -} - -/** The slice of OpenAI's chat-completion stream chunks this provider reads. */ -interface OpenAIStreamEvent { - choices?: { delta?: { content?: string }; finish_reason?: string | null }[]; - usage?: { prompt_tokens?: number; completion_tokens?: number }; -} - -function resolveFetch(fetch?: FetchLike): FetchLike { - if (fetch !== undefined) { - return fetch; - } - if (typeof globalThis.fetch === "function") { - return globalThis.fetch.bind(globalThis); - } - throw new Error("no fetch implementation available; pass one via options.fetch"); -} diff --git a/packages/llm/src/providers/support.ts b/packages/llm/src/providers/support.ts deleted file mode 100644 index dbe0c38..0000000 --- a/packages/llm/src/providers/support.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { - type Logger, - makeMetrics, - type Metrics, - type ObservabilityDeps, -} from "@primandproper/observability"; -import { exponentialBackoff, type Policy, type RetryConfig } from "@primandproper/retry"; - -type Counter = ReturnType; - -/** Thirty seconds, in milliseconds — the default per-request deadline for a vendor call. */ -export const DEFAULT_LLM_TIMEOUT_MS = 30_000; - -/** - * Whether a vendor response status is worth retrying. Anthropic/OpenAI document overload (429) - * and 5xx as transient; a 4xx is a client error (bad key, malformed request) a replay won't fix. - */ -export function isRetryableStatus(status: number): boolean { - return status === 408 || status === 429 || status >= 500; -} - -/** - * Internal marker thrown so the retry policy re-attempts a retryable-status response; it carries - * the {@link Response} so the provider still reads the vendor's status/body once retries run out. - */ -class RetryableResponseError extends Error { - readonly response: Response; - constructor(response: Response) { - super(`retryable HTTP status ${String(response.status)}`); - this.name = "RetryableResponseError"; - this.response = response; - } -} - -/** Builds a per-request timeout signal combined with the caller's signal, if any. */ -function requestSignal(timeoutMs: number, caller?: AbortSignal): AbortSignal | undefined { - const timeout = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined; - if (timeout === undefined) return caller; - if (caller === undefined) return timeout; - return AbortSignal.any([caller, timeout]); -} - -/** Per-call resilience: a timeout deadline and an optional retry policy. */ -export interface ResilienceOptions { - /** Per-attempt deadline in milliseconds; `0` disables it. */ - timeoutMs: number; - /** Retry policy for transient failures. Omitted means a single attempt. */ - retry?: Policy | undefined; - /** A caller abort signal, honored across attempts (including the backoff sleep). */ - signal?: AbortSignal | undefined; -} - -/** - * Runs a vendor `fetch` with a per-attempt timeout and, when a policy is supplied, retries on - * transient failures (network/timeout errors and 429/5xx responses). A non-retryable response - * (a 4xx, or any 2xx) is returned unchanged for the provider's normal handling; each attempt gets - * a fresh timeout window and the caller's `signal` cancels the whole loop, backoff included. - */ -export async function resilientFetch( - doFetch: (signal: AbortSignal | undefined) => Promise, - opts: ResilienceOptions, -): Promise { - const attempt = async (): Promise => { - const response = await doFetch(requestSignal(opts.timeoutMs, opts.signal)); - if (opts.retry !== undefined && !response.ok && isRetryableStatus(response.status)) { - throw new RetryableResponseError(response); - } - return response; - }; - - if (opts.retry === undefined) { - return attempt(); - } - try { - return await opts.retry.run(attempt, { signal: opts.signal }); - } catch (err) { - if (err instanceof RetryableResponseError) { - return err.response; - } - throw err; - } -} - -/** Builds a retry {@link Policy} from config, wiring the provider's logger for retry log lines. */ -export function retryPolicy( - config: RetryConfig | undefined, - logger: Logger, -): Policy | undefined { - return config === undefined ? undefined : exponentialBackoff(config, { logger }); -} - -/** - * The `llm.tokens.input` / `llm.tokens.output` counter pair every REST LLM provider mints in its - * constructor. Token usage is the single most valuable LLM signal — it drives cost — so parsed - * usage is turned into metrics tagged by model. The meter is registered under the component's - * o11y name so instruments group with its spans and logs. - */ -export interface TokenInstruments { - inputTokens: Counter; - outputTokens: Counter; -} - -/** Builds the `llm.tokens.input` / `llm.tokens.output` counters, defaulting to the noop meter. */ -export function tokenInstruments( - o11yName: string, - deps: ObservabilityDeps | undefined, -): TokenInstruments { - const metrics = makeMetrics(o11yName, deps?.metrics); - return { - inputTokens: metrics.counter("llm.tokens.input", { - description: "Input tokens consumed by LLM completions, tagged by model.", - }), - outputTokens: metrics.counter("llm.tokens.output", { - description: "Output tokens produced by LLM completions, tagged by model.", - }), - }; -} - -/** - * Yields each SSE `data:` payload from a streaming response body, in order. Both Anthropic and - * OpenAI frame their streams as `data: ` lines (Anthropic adds an `event:` line the JSON's - * own `type` field makes redundant, so it is ignored); a literal `[DONE]` sentinel ends the OpenAI - * stream and is not yielded. Decodes incrementally and buffers partial lines across chunks, so no - * whole-payload buffering occurs. Throws if the body is absent. - */ -export async function* sseDataLines( - body: ReadableStream | null, -): AsyncGenerator { - if (body === null) { - throw new Error("streaming response has no body"); - } - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; - } - buffer += decoder.decode(value, { stream: true }); - let newline = buffer.indexOf("\n"); - while (newline !== -1) { - const line = buffer.slice(0, newline).trimEnd(); - buffer = buffer.slice(newline + 1); - const payload = sseData(line); - if (payload !== undefined) { - if (payload === "[DONE]") { - return; - } - yield payload; - } - newline = buffer.indexOf("\n"); - } - } - // Flush a trailing line with no closing newline. - const payload = sseData(buffer.trimEnd()); - if (payload !== undefined && payload !== "[DONE]") { - yield payload; - } - } finally { - reader.releaseLock(); - } -} - -/** Extracts the payload of an SSE `data:` line, or `undefined` for any other line. */ -function sseData(line: string): string | undefined { - if (!line.startsWith("data:")) { - return undefined; - } - return line.slice("data:".length).trimStart(); -} - -/** - * Pulls the vendor request id off a failed response's headers, trying each name in order. - * Anthropic returns `request-id`; OpenAI returns `x-request-id`. Returns `undefined` when the - * header is absent so the caller can decide what to record. - */ -export function requestIdFromResponse( - response: Response, - ...names: string[] -): string | undefined { - for (const name of names) { - const value = response.headers.get(name); - if (value !== null && value !== "") { - return value; - } - } - return undefined; -} diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json deleted file mode 100644 index bdb9327..0000000 --- a/packages/llm/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022"], - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/llm/tsup.config.ts b/packages/llm/tsup.config.ts deleted file mode 100644 index 8bf2394..0000000 --- a/packages/llm/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { index: "src/index.ts" }, - platform: "node", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: true, -}); diff --git a/packages/messagequeue/CHANGELOG.md b/packages/messagequeue/CHANGELOG.md deleted file mode 100644 index 4edf974..0000000 --- a/packages/messagequeue/CHANGELOG.md +++ /dev/null @@ -1,38 +0,0 @@ -# @primandproper/messagequeue - -## 0.2.0 - -### Minor Changes - -- a124406: Make `Publisher.stop()` and `PublisherProvider.close()` async (now return `Promise`) and add a required `close()` to `ConsumerProvider`. Breaking for callers and implementers. - -### Patch Changes - -- Updated dependencies [a124406] -- Updated dependencies [a124406] - - @primandproper/errors@0.0.2 - - @primandproper/observability@0.1.0 - -## 0.1.0 - -### Minor Changes - -- db7c3ec: rewrite `messagequeue` as a faithful port of `platform-go/messagequeue`. **Breaking:** the previous - combined `MessageQueue` interface (`publish(topic, message)` / `subscribe`, Redis Streams) is - replaced by Go's actual shape — separate `PublisherProvider` and `ConsumerProvider` roots that hand - out topic-bound `Publisher`s (`publish(data)` / `publishAsync(data)` / `stop()`) and `Consumer`s - (`consume(signal, onError)`), with a raw-bytes `ConsumerFunc`. `provideMessageQueue` is gone; use - `providePublisherProvider(config, deps)` and `provideConsumerProvider(config, deps)` (mirroring Go's - `ProvidePublisherProvider` / `ProvideConsumerProvider`). - - Go's `context`/`stopChan`/`errors`-channel `Consume` signature is translated idiomatically: an - `AbortSignal` unifies cancellation and stop, and an `onError` callback stands in for the errors - channel. Payloads are JSON-encoded on publish and delivered to handlers as `Uint8Array`, matching - Go's `encoding.ClientEncoder` + `[]byte` handlers. - - Providers now cover the full Go roster plus a local-first bonus: **redis** (faithful PUB/SUB via - ioredis, replacing the old Streams design — including the SUBSCRIBE-confirmation race fix), **sqs** - (`@aws-sdk/client-sqs`, long-poll + delete-on-success), **pubsub** (`@google-cloud/pubsub`, - ack/nack), **kafka** (`kafkajs`, manual commit-after-handle), **noop**, and **memory** (an - in-process broker, not in Go, for zero-infrastructure tests and single-process apps). `QueuesConfig` - (the platform topic-name set) is ported as a Zod schema. `ErrEmptyTopicName` mirrors Go's sentinel. diff --git a/packages/messagequeue/package.json b/packages/messagequeue/package.json deleted file mode 100644 index 1ac9342..0000000 --- a/packages/messagequeue/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@primandproper/messagequeue", - "version": "0.2.0", - "type": "module", - "description": "Server-only message queue: Publisher/Consumer interfaces with swappable providers.", - "license": "AGPL-3.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/primandproper/platform-ts.git", - "directory": "packages/messagequeue" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "eslint .", - "clean": "rm -rf dist .turbo" - }, - "dependencies": { - "@aws-sdk/client-sqs": "3.699.0", - "@google-cloud/pubsub": "5.3.1", - "@primandproper/errors": "workspace:*", - "@primandproper/observability": "workspace:*", - "ioredis": "5.11.1", - "kafkajs": "2.2.4", - "zod": "3.25.76" - } -} diff --git a/packages/messagequeue/src/config.ts b/packages/messagequeue/src/config.ts deleted file mode 100644 index adbcdd2..0000000 --- a/packages/messagequeue/src/config.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { z } from "zod"; - -/** In-process-broker config. No tuning; present for symmetry. */ -export const MemoryMessageQueueConfigSchema = z.object({}); -export type MemoryMessageQueueConfig = z.infer; - -/** Redis PUB/SUB config. Faithful to Go's `redis.Config`. */ -export const RedisMessageQueueConfigSchema = z.object({ - /** One or more `host:port` addresses; more than one selects a cluster client. */ - queueAddresses: z.array(z.string()).min(1), - username: z.string().optional(), - password: z.string().optional(), -}); -export type RedisMessageQueueConfig = z.infer; - -/** Amazon SQS config. Faithful to Go's `sqs.Config` (topic = queue URL); AWS creds resolve ambiently. */ -export const SQSMessageQueueConfigSchema = z.object({ - region: z.string().optional(), - endpoint: z.string().url().optional(), - credentials: z - .object({ - accessKeyId: z.string(), - secretAccessKey: z.string(), - sessionToken: z.string().optional(), - }) - .optional(), -}); -export type SQSMessageQueueConfig = z.infer; - -/** GCP Pub/Sub config. Faithful to Go's `pubsub.Config`. */ -export const PubSubMessageQueueConfigSchema = z.object({ - projectId: z.string().min(1), -}); -export type PubSubMessageQueueConfig = z.infer; - -/** Kafka config. Faithful to Go's `kafka.Config`. */ -export const KafkaMessageQueueConfigSchema = z.object({ - brokers: z.array(z.string()).min(1), - groupId: z.string().default(""), -}); -export type KafkaMessageQueueConfig = z.infer; - -/** The set of message-queue providers. Mirrors Go's `provider` constants plus TS-only `memory`. */ -export const MESSAGE_QUEUE_PROVIDERS = [ - "memory", - "redis", - "sqs", - "pubsub", - "kafka", - "noop", -] as const; - -/** - * Selects and configures a provider for one role (publishing or consuming). Replaces the Go - * `env:`-tagged `MessageQueueConfig` + ozzo `ValidateWithContext`. A process that publishes to one - * backend and consumes from another simply builds each role from its own config — more flexible - * than Go's fixed `Config{Consumer, Publisher}` wrapper, and the same switch under the hood. - */ -export const MessageQueueConfigSchema = z - .object({ - provider: z.enum(MESSAGE_QUEUE_PROVIDERS).default("memory"), - memory: MemoryMessageQueueConfigSchema.optional(), - redis: RedisMessageQueueConfigSchema.optional(), - sqs: SQSMessageQueueConfigSchema.optional(), - pubsub: PubSubMessageQueueConfigSchema.optional(), - kafka: KafkaMessageQueueConfigSchema.optional(), - }) - .superRefine((cfg, ctx) => { - const required: Record = { - redis: cfg.redis, - pubsub: cfg.pubsub, - kafka: cfg.kafka, - }; - const missing = required[cfg.provider]; - if (cfg.provider in required && missing === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [cfg.provider], - message: `${cfg.provider} config is required when provider is '${cfg.provider}'`, - }); - } - }); - -export type MessageQueueConfig = z.infer; -export type MessageQueueConfigInput = z.input; - -/** - * The platform's queue names. Faithful to Go's `QueuesConfig`; every name is required so a - * misconfigured deployment fails fast rather than publishing to an empty topic. - */ -export const QueuesConfigSchema = z.object({ - dataChangesTopicName: z.string().min(1), - outboundEmailsTopicName: z.string().min(1), - searchIndexRequestsTopicName: z.string().min(1), - mobileNotificationsTopicName: z.string().min(1), - userDataAggregationTopicName: z.string().min(1), - webhookExecutionRequestsTopicName: z.string().min(1), -}); -export type QueuesConfig = z.infer; -export type QueuesConfigInput = z.input; diff --git a/packages/messagequeue/src/index.ts b/packages/messagequeue/src/index.ts deleted file mode 100644 index dde9821..0000000 --- a/packages/messagequeue/src/index.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { ObservabilityDeps } from "@primandproper/observability"; - -import { MessageQueueConfigSchema, type MessageQueueConfigInput } from "./config.js"; -import type { ConsumerProvider, PublisherProvider } from "./messagequeue.js"; -import { KafkaConsumerProvider, KafkaPublisherProvider } from "./providers/kafka.node.js"; -import { - provideMemoryConsumerProvider, - provideMemoryPublisherProvider, -} from "./providers/memory.js"; -import { NoopConsumerProvider, NoopPublisherProvider } from "./providers/noop.js"; -import { - PubSubConsumerProvider, - PubSubPublisherProvider, -} from "./providers/pubsub.node.js"; -import { RedisConsumerProvider, RedisPublisherProvider } from "./providers/redis.node.js"; -import { SQSConsumerProvider, SQSPublisherProvider } from "./providers/sqs.node.js"; - -export * from "./messagequeue.js"; -export * from "./config.js"; - -export { MemoryBroker } from "./providers/memory.js"; -export { - provideMemoryConsumerProvider, - provideMemoryPublisherProvider, - MemoryConsumerProvider, - MemoryPublisherProvider, -} from "./providers/memory.js"; -export { - NoopConsumerProvider, - NoopPublisherProvider, - NoopConsumer, - NoopPublisher, -} from "./providers/noop.js"; -export { - RedisConsumerProvider, - RedisPublisherProvider, - type RedisMessageQueueOptions, -} from "./providers/redis.node.js"; -export { - SQSConsumerProvider, - SQSPublisherProvider, - type SQSMessageQueueOptions, -} from "./providers/sqs.node.js"; -export { - PubSubConsumerProvider, - PubSubPublisherProvider, - type PubSubMessageQueueOptions, -} from "./providers/pubsub.node.js"; -export { - KafkaConsumerProvider, - KafkaPublisherProvider, - type KafkaMessageQueueOptions, -} from "./providers/kafka.node.js"; - -/** - * Validates config and returns the matching {@link PublisherProvider}. Mirrors Go's - * `ProvidePublisherProvider`. `memory` (default) and `noop` need no further config; `redis`, - * `pubsub`, and `kafka` require their config block (enforced by the schema); `sqs` resolves AWS - * credentials ambiently unless overridden. - */ -export function providePublisherProvider( - config?: MessageQueueConfigInput, - deps?: ObservabilityDeps, -): PublisherProvider { - const cfg = MessageQueueConfigSchema.parse(config ?? {}); - switch (cfg.provider) { - case "memory": - return provideMemoryPublisherProvider(deps); - case "redis": - return new RedisPublisherProvider(required(cfg.redis, "redis"), deps); - case "sqs": - return new SQSPublisherProvider(cfg.sqs ?? {}, deps); - case "pubsub": - return new PubSubPublisherProvider(required(cfg.pubsub, "pubsub"), deps); - case "kafka": - return new KafkaPublisherProvider(required(cfg.kafka, "kafka"), deps); - case "noop": - return new NoopPublisherProvider(); - } -} - -/** - * Validates config and returns the matching {@link ConsumerProvider}. Mirrors Go's - * `ProvideConsumerProvider`. See {@link providePublisherProvider} for per-provider config rules. - */ -export function provideConsumerProvider( - config?: MessageQueueConfigInput, - deps?: ObservabilityDeps, -): ConsumerProvider { - const cfg = MessageQueueConfigSchema.parse(config ?? {}); - switch (cfg.provider) { - case "memory": - return provideMemoryConsumerProvider(deps); - case "redis": - return new RedisConsumerProvider(required(cfg.redis, "redis"), deps); - case "sqs": - return new SQSConsumerProvider(cfg.sqs ?? {}, deps); - case "pubsub": - return new PubSubConsumerProvider(required(cfg.pubsub, "pubsub"), deps); - case "kafka": - return new KafkaConsumerProvider(required(cfg.kafka, "kafka"), deps); - case "noop": - return new NoopConsumerProvider(); - } -} - -/** - * Narrows a per-provider config the schema's `superRefine` has already guaranteed present, so the - * type checker sees a defined value without a non-null assertion. - */ -function required(value: T | undefined, provider: string): T { - if (value === undefined) { - throw new Error(`${provider} config is required when provider is '${provider}'`); - } - return value; -} diff --git a/packages/messagequeue/src/messagequeue.test.ts b/packages/messagequeue/src/messagequeue.test.ts deleted file mode 100644 index 17e3eb6..0000000 --- a/packages/messagequeue/src/messagequeue.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -import type { MeterProvider } from "@primandproper/observability"; -import { describe, expect, it, vi } from "vitest"; - -import { parseAddress } from "./providers/redis.node.js"; - -import { - type ConsumerProvider, - ErrConsumerHandlerMismatch, - ErrEmptyTopicName, - KafkaConsumerProvider, - KafkaPublisherProvider, - MemoryBroker, - MemoryConsumerProvider, - MemoryPublisherProvider, - NoopConsumerProvider, - NoopPublisherProvider, - provideConsumerProvider, - providePublisherProvider, - type PublisherProvider, - PubSubConsumerProvider, - PubSubPublisherProvider, - RedisConsumerProvider, - RedisPublisherProvider, - SQSConsumerProvider, - SQSPublisherProvider, -} from "./index.js"; - -const decode = (data: Uint8Array): unknown => JSON.parse(new TextDecoder().decode(data)); - -/** Waits a microtask-ish beat so background delivery loops can run. */ -const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); - -describe("MemoryBroker round-trip", () => { - it("delivers a published message to a consuming consumer", async () => { - const broker = new MemoryBroker(); - const publishers = new MemoryPublisherProvider(broker); - const consumers = new MemoryConsumerProvider(broker); - - const received: unknown[] = []; - const consumer = await consumers.provideConsumer("topic", (data) => { - received.push(decode(data)); - return Promise.resolve(); - }); - - const stop = new AbortController(); - void consumer.consume(stop.signal); - - const publisher = await publishers.providePublisher("topic"); - await publisher.publish({ hello: "world" }); - - expect(received).toEqual([{ hello: "world" }]); - stop.abort(); - }); - - it("reports handler errors through onError without throwing to the publisher", async () => { - const broker = new MemoryBroker(); - const consumers = new MemoryConsumerProvider(broker); - const publishers = new MemoryPublisherProvider(broker); - - const boom = new Error("handler blew up"); - const errors: unknown[] = []; - const consumer = await consumers.provideConsumer("topic", () => Promise.reject(boom)); - - const stop = new AbortController(); - void consumer.consume(stop.signal, (err) => errors.push(err)); - - const publisher = await publishers.providePublisher("topic"); - await expect(publisher.publish({ n: 1 })).resolves.toBeUndefined(); - - expect(errors).toEqual([boom]); - stop.abort(); - }); - - it("stops delivery once the consume signal aborts", async () => { - const broker = new MemoryBroker(); - const handler = vi.fn(() => Promise.resolve()); - const consumer = await new MemoryConsumerProvider(broker).provideConsumer( - "topic", - handler, - ); - const publisher = await new MemoryPublisherProvider(broker).providePublisher("topic"); - - const stop = new AbortController(); - const done = consumer.consume(stop.signal); - stop.abort(); - await done; - - await publisher.publish({ n: 1 }); - expect(handler).not.toHaveBeenCalled(); - }); -}); - -/** A meter provider that tallies counter `add`s by instrument name. */ -function recordingMeter(): { provider: MeterProvider; counts: Record } { - const counts: Record = {}; - const counter = (name: string) => ({ - add: (value: number) => { - counts[name] = (counts[name] ?? 0) + value; - }, - }); - const meter = { - createCounter: (name: string) => counter(name), - createUpDownCounter: (name: string) => counter(name), - createHistogram: () => ({ record: () => undefined }), - createGauge: () => ({ record: () => undefined }), - }; - return { - provider: { getMeter: () => meter } as unknown as MeterProvider, - counts, - }; -} - -describe("consumer handler cache (MQ-2)", () => { - it("returns the same consumer for the same topic+handler", async () => { - const consumers = new MemoryConsumerProvider(new MemoryBroker()); - const handler = (): Promise => Promise.resolve(); - const a = await consumers.provideConsumer("topic", handler); - const b = await consumers.provideConsumer("topic", handler); - expect(a).toBe(b); - }); - - it("rejects a second consumer for the same topic with a different handler", async () => { - const consumers = new MemoryConsumerProvider(new MemoryBroker()); - await consumers.provideConsumer("topic", () => Promise.resolve()); - await expect( - consumers.provideConsumer("topic", () => Promise.resolve()), - ).rejects.toBe(ErrConsumerHandlerMismatch); - }); -}); - -describe("consumer metrics (MQ-3)", () => { - it("counts consumed only after a successful handler and errors on failure", async () => { - const broker = new MemoryBroker(); - const { provider, counts } = recordingMeter(); - const consumers = new MemoryConsumerProvider(broker, { metrics: provider }); - const publishers = new MemoryPublisherProvider(broker); - - let calls = 0; - const consumer = await consumers.provideConsumer("topic", () => { - calls += 1; - // Fail the first message, succeed the second. - return calls === 1 ? Promise.reject(new Error("boom")) : Promise.resolve(); - }); - const stop = new AbortController(); - void consumer.consume(stop.signal, () => undefined); - - const publisher = await publishers.providePublisher("topic"); - await publisher.publish({ n: 1 }); - await publisher.publish({ n: 2 }); - await tick(); - - expect(counts.topic_consumed).toBe(1); - expect(counts.topic_consume_errors).toBe(1); - stop.abort(); - }); -}); - -describe("parseAddress (MQ-4)", () => { - it("parses IPv4 host:port and bare host", () => { - expect(parseAddress("localhost:6379")).toEqual({ host: "localhost", port: 6379 }); - expect(parseAddress("redis.example.com")).toEqual({ - host: "redis.example.com", - port: 6379, - }); - }); - - it("parses bracketed IPv6 with and without a port", () => { - expect(parseAddress("[::1]:6380")).toEqual({ host: "::1", port: 6380 }); - expect(parseAddress("[2001:db8::1]:6379")).toEqual({ - host: "2001:db8::1", - port: 6379, - }); - expect(parseAddress("[::1]")).toEqual({ host: "::1", port: 6379 }); - }); - - it("treats a bare IPv6 literal as the host with the default port", () => { - expect(parseAddress("::1")).toEqual({ host: "::1", port: 6379 }); - expect(parseAddress("2001:db8::1")).toEqual({ host: "2001:db8::1", port: 6379 }); - }); -}); - -describe("empty topic", () => { - it("rejects providePublisher with ErrEmptyTopicName", async () => { - await expect(new MemoryPublisherProvider().providePublisher("")).rejects.toBe( - ErrEmptyTopicName, - ); - }); - - it("rejects provideConsumer with ErrEmptyTopicName", async () => { - await expect( - new MemoryConsumerProvider().provideConsumer("", () => Promise.resolve()), - ).rejects.toBe(ErrEmptyTopicName); - }); -}); - -describe("noop providers", () => { - it("publishes and pings without throwing", async () => { - const provider: PublisherProvider = new NoopPublisherProvider(); - const publisher = await provider.providePublisher("topic"); - await expect(publisher.publish({ n: 1 })).resolves.toBeUndefined(); - await expect(provider.ping()).resolves.toBeUndefined(); - expect(() => { - publisher.publishAsync({ n: 1 }); - }).not.toThrow(); - await expect(provider.close()).resolves.toBeUndefined(); - }); - - it("consume resolves once aborted", async () => { - const provider: ConsumerProvider = new NoopConsumerProvider(); - const consumer = await provider.provideConsumer("topic", () => Promise.resolve()); - const stop = new AbortController(); - const done = consumer.consume(stop.signal); - stop.abort(); - await expect(done).resolves.toBeUndefined(); - }); -}); - -describe("publishAsync", () => { - it("swallows publish errors instead of rejecting", async () => { - const broker = new MemoryBroker(); - const publisher = await new MemoryPublisherProvider(broker).providePublisher("topic"); - - // A value with a circular reference makes JSON encoding throw inside publish. - const circular: Record = {}; - circular.self = circular; - - expect(() => { - publisher.publishAsync(circular); - }).not.toThrow(); - await tick(); - }); -}); - -describe("providePublisherProvider", () => { - it("defaults to the memory provider", () => { - expect(providePublisherProvider(undefined, {})).toBeInstanceOf( - MemoryPublisherProvider, - ); - }); - - it("builds a noop provider", () => { - expect(providePublisherProvider({ provider: "noop" })).toBeInstanceOf( - NoopPublisherProvider, - ); - }); - - it("builds a redis provider when configured", () => { - expect( - providePublisherProvider({ - provider: "redis", - redis: { queueAddresses: ["localhost:6379"] }, - }), - ).toBeInstanceOf(RedisPublisherProvider); - }); - - it("builds an sqs provider", () => { - expect(providePublisherProvider({ provider: "sqs" })).toBeInstanceOf( - SQSPublisherProvider, - ); - }); - - it("builds a pubsub provider when configured", () => { - expect( - providePublisherProvider({ provider: "pubsub", pubsub: { projectId: "p" } }), - ).toBeInstanceOf(PubSubPublisherProvider); - }); - - it("builds a kafka provider when configured", () => { - expect( - providePublisherProvider({ - provider: "kafka", - kafka: { brokers: ["localhost:9092"] }, - }), - ).toBeInstanceOf(KafkaPublisherProvider); - }); - - it("rejects redis/pubsub/kafka without their config block", () => { - expect(() => providePublisherProvider({ provider: "redis" })).toThrow(); - expect(() => providePublisherProvider({ provider: "pubsub" })).toThrow(); - expect(() => providePublisherProvider({ provider: "kafka" })).toThrow(); - }); -}); - -describe("provideConsumerProvider", () => { - it("defaults to the memory provider", () => { - expect(provideConsumerProvider(undefined, {})).toBeInstanceOf(MemoryConsumerProvider); - }); - - it("builds each configured provider", () => { - expect(provideConsumerProvider({ provider: "noop" })).toBeInstanceOf( - NoopConsumerProvider, - ); - expect(provideConsumerProvider({ provider: "sqs" })).toBeInstanceOf( - SQSConsumerProvider, - ); - expect( - provideConsumerProvider({ - provider: "redis", - redis: { queueAddresses: ["localhost:6379"] }, - }), - ).toBeInstanceOf(RedisConsumerProvider); - expect( - provideConsumerProvider({ provider: "pubsub", pubsub: { projectId: "p" } }), - ).toBeInstanceOf(PubSubConsumerProvider); - expect( - provideConsumerProvider({ - provider: "kafka", - kafka: { brokers: ["localhost:9092"] }, - }), - ).toBeInstanceOf(KafkaConsumerProvider); - }); -}); - -/** - * Live Redis PUB/SUB integration is opt-in: set MESSAGEQUEUE_TEST_REDIS_ADDR to a reachable - * `host:port` (e.g. localhost:6379). The default offline run skips it and stays green. - */ -const REDIS_ADDR = process.env.MESSAGEQUEUE_TEST_REDIS_ADDR; - -describe.skipIf(!REDIS_ADDR)("Redis PUB/SUB (live)", () => { - it("delivers a published message across publisher and consumer providers", async () => { - const options = { queueAddresses: [REDIS_ADDR ?? "localhost:6379"] }; - const topic = `mqtest:${Math.trunc(performance.now()).toString()}`; - - const consumers = new RedisConsumerProvider(options); - const publishers = new RedisPublisherProvider(options); - - const received = new Promise((resolve) => { - void consumers - .provideConsumer(topic, (data) => { - resolve(decode(data)); - return Promise.resolve(); - }) - .then((consumer) => consumer.consume(new AbortController().signal)); - }); - - await tick(); - const publisher = await publishers.providePublisher(topic); - // Give the SUBSCRIBE a beat to land before publishing. - await new Promise((r) => setTimeout(r, 100)); - await publisher.publish({ hello: "redis" }); - - expect(await received).toEqual({ hello: "redis" }); - await publishers.close(); - await consumers.close(); - }); -}); diff --git a/packages/messagequeue/src/messagequeue.ts b/packages/messagequeue/src/messagequeue.ts deleted file mode 100644 index 0583dc5..0000000 --- a/packages/messagequeue/src/messagequeue.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { PlatformError } from "@primandproper/errors"; - -/** - * Handles a single consumed message. Faithful to Go's - * `ConsumerFunc func(context.Context, []byte) error`: it receives the raw payload bytes and - * signals a handling error by rejecting/throwing (the analogue of Go's returned `error`). - * The queue stays a dumb byte transport — callers own their own decoding. - */ -export type ConsumerFunc = (data: Uint8Array) => Promise; - -/** - * Produces events onto a single topic. Obtained from {@link PublisherProvider.providePublisher}; - * a publisher is bound to the topic it was created for, exactly like Go's `Publisher`. - */ -export interface Publisher { - /** - * Writes a message onto the queue. `data` is JSON-encoded before it hits the wire, so callers - * pass their own structs/objects rather than pre-serialized bytes — mirrors Go's - * `Publish(ctx, data any) error`. - */ - publish(data: unknown): Promise; - /** - * Like {@link publish}, but errors are logged instead of surfaced — fire-and-forget. The - * analogue of Go's `PublishAsync(ctx, data any)`. - */ - publishAsync(data: unknown): void; - /** - * Halts all publishing and releases any per-publisher resources, awaiting any pending flush so - * buffered messages are not dropped on shutdown. Go's `Stop()`, made awaitable. - */ - stop(): Promise; -} - -/** - * Provides a {@link Publisher} for a given topic, caching one publisher per topic. The analogue - * of Go's `PublisherProvider`. - */ -export interface PublisherProvider { - /** - * Returns the {@link Publisher} for `topic`, building and caching it on first use. Rejects with - * {@link ErrEmptyTopicName} when `topic` is empty. - */ - providePublisher(topic: string): Promise; - /** Verifies the backing broker is reachable. Go's `Ping(ctx) error`. */ - ping(): Promise; - /** - * Closes the underlying client and every cached publisher, awaiting any in-flight flush so - * buffered messages are not dropped on shutdown. Go's `Close()`, made awaitable. - */ - close(): Promise; -} - -/** - * Consumes events from a single topic. Obtained from {@link ConsumerProvider.provideConsumer}. - * The analogue of Go's `Consumer`. - */ -export interface Consumer { - /** - * Delivers messages to the handler until `signal` aborts, resolving once consumption has fully - * stopped. This is the idiomatic translation of Go's - * `Consume(ctx, stopChan chan bool, errors chan error)`: the `signal` unifies Go's `ctx` and - * `stopChan` (abort it to stop the loop), and `onError` receives errors raised while - * consuming — the analogue of writing to Go's `errors` channel. With no `signal`, consumption - * runs until the process exits, matching Go's nil-`stopChan` behaviour. - */ - consume(signal?: AbortSignal, onError?: (err: unknown) => void): Promise; -} - -/** - * Provides a {@link Consumer} for a given topic, caching one consumer per topic. The analogue of - * Go's `ConsumerProvider`. - */ -export interface ConsumerProvider { - /** - * Returns the {@link Consumer} for `topic`, building and caching it on first use. Rejects with - * {@link ErrEmptyTopicName} when `topic` is empty. - */ - provideConsumer(topic: string, handler: ConsumerFunc): Promise; - /** - * Closes the underlying client and disconnects every cached consumer so consumer-side broker - * connections (SQS/Pub/Sub clients, Kafka readers, Redis subscriber sockets) are released on - * shutdown rather than leaking. Idempotent — safe to call more than once. - */ - close(): Promise; -} - -/** Returned when a topic name is empty. Mirrors Go's `ErrEmptyTopicName`. */ -export const ErrEmptyTopicName = new PlatformError( - "messagequeue/empty-topic-name", - "empty topic name", -); - -/** - * Rejected when `provideConsumer` is called again for a topic that already has a consumer bound - * to a *different* handler. The per-topic consumer cache would otherwise silently return the - * first handler's consumer and drop the second, so the mismatch is surfaced instead. - */ -export const ErrConsumerHandlerMismatch = new PlatformError( - "messagequeue/consumer-handler-mismatch", - "a consumer for this topic already exists with a different handler", -); diff --git a/packages/messagequeue/src/providers/kafka.node.test.ts b/packages/messagequeue/src/providers/kafka.node.test.ts deleted file mode 100644 index 9eb1a02..0000000 --- a/packages/messagequeue/src/providers/kafka.node.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { KafkaConsumerProvider } from "../index.js"; - -type EachMessage = (arg: { - topic: string; - partition: number; - message: { value: Buffer; offset: string }; -}) => Promise; - -const h = vi.hoisted(() => ({ - eachMessage: undefined as EachMessage | undefined, - commitOffsets: vi.fn<(offsets: unknown) => Promise>(() => Promise.resolve()), - disconnect: vi.fn<() => Promise>(() => Promise.resolve()), - listeners: new Map void>(), -})); - -/** Fires the CRASH event kafkajs would emit on a consumer crash. */ -const crash = (error: unknown, restart: boolean): void => { - h.listeners.get("consumer.crash")?.({ payload: { error, restart } }); -}; - -vi.mock("kafkajs", () => { - const consumer = { - connect: () => Promise.resolve(), - subscribe: () => Promise.resolve(), - run: (opts: { eachMessage: EachMessage }) => { - h.eachMessage = opts.eachMessage; - return Promise.resolve(); - }, - commitOffsets: h.commitOffsets, - disconnect: h.disconnect, - events: { CRASH: "consumer.crash", DISCONNECT: "consumer.disconnect" }, - on: (event: string, cb: (event: { payload: unknown }) => void) => { - h.listeners.set(event, cb); - return () => h.listeners.delete(event); - }, - }; - class Kafka { - consumer(): typeof consumer { - return consumer; - } - producer(): unknown { - return {}; - } - admin(): unknown { - return { connect: () => Promise.resolve(), disconnect: () => Promise.resolve() }; - } - } - return { Kafka }; -}); - -const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); - -const deliver = (topic: string, partition: number, offset: string): Promise => { - if (!h.eachMessage) { - throw new Error("eachMessage not registered"); - } - return h.eachMessage({ - topic, - partition, - message: { value: Buffer.from("{}"), offset }, - }); -}; - -describe("KafkaConsumer offset safety (MQ-1)", () => { - it("does not commit past a failed message", async () => { - const boom = new Error("handler failed"); - let calls = 0; - const handler = (): Promise => { - calls += 1; - return calls === 1 ? Promise.reject(boom) : Promise.resolve(); - }; - - const provider = new KafkaConsumerProvider({ brokers: ["broker"], groupId: "g" }); - const errors: unknown[] = []; - const consumer = await provider.provideConsumer("topic", handler); - - const stop = new AbortController(); - void consumer.consume(stop.signal, (err) => errors.push(err)); - await tick(); - - // First delivery of offset 5 fails: eachMessage rejects so kafkajs won't advance, and nothing - // is committed. - await expect(deliver("topic", 0, "5")).rejects.toBe(boom); - expect(h.commitOffsets).not.toHaveBeenCalled(); - expect(errors).toEqual([boom]); - - // Redelivery of the SAME offset succeeds and commits 6 — never a later offset that would have - // skipped past 5. - await deliver("topic", 0, "5"); - expect(h.commitOffsets).toHaveBeenCalledTimes(1); - expect(h.commitOffsets).toHaveBeenCalledWith([ - { topic: "topic", partition: 0, offset: "6" }, - ]); - - stop.abort(); - }); -}); - -describe("KafkaConsumer death visibility (LC-9)", () => { - it("surfaces a fatal crash through onError and resolves consume()", async () => { - h.disconnect.mockClear(); - const provider = new KafkaConsumerProvider({ brokers: ["broker"], groupId: "g" }); - const consumer = await provider.provideConsumer("topic", () => Promise.resolve()); - - const errors: unknown[] = []; - const done = consumer.consume(new AbortController().signal, (err) => - errors.push(err), - ); - await tick(); // let connect/subscribe/run settle and register the crash listener - - const boom = new Error("non-retriable"); - crash(boom, false); - - await expect(done).resolves.toBeUndefined(); // no longer hangs forever - expect(errors).toEqual([boom]); - expect(h.disconnect).toHaveBeenCalled(); - }); - - it("surfaces a retriable crash through onError but keeps consuming", async () => { - h.disconnect.mockClear(); - const provider = new KafkaConsumerProvider({ brokers: ["broker"], groupId: "g" }); - const consumer = await provider.provideConsumer("topic2", () => Promise.resolve()); - - const errors: unknown[] = []; - const stop = new AbortController(); - void consumer.consume(stop.signal, (err) => errors.push(err)); - await tick(); - - crash(new Error("transient"), true); - - expect(errors).toHaveLength(1); - expect(h.disconnect).not.toHaveBeenCalled(); // restart=true must not tear the reader down - stop.abort(); - }); -}); - -describe("KafkaConsumerProvider close (LC-4)", () => { - it("disconnects cached consumers on close", async () => { - h.disconnect.mockClear(); - const provider = new KafkaConsumerProvider({ brokers: ["broker"], groupId: "g" }); - await provider.provideConsumer("topic3", () => Promise.resolve()); - - await provider.close(); - - expect(h.disconnect).toHaveBeenCalled(); - }); -}); diff --git a/packages/messagequeue/src/providers/kafka.node.ts b/packages/messagequeue/src/providers/kafka.node.ts deleted file mode 100644 index 2e2dc51..0000000 --- a/packages/messagequeue/src/providers/kafka.node.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import { type Consumer as KafkaReader, Kafka, type Producer } from "kafkajs"; - -import { - ErrEmptyTopicName, - type Consumer, - type ConsumerFunc, - type ConsumerProvider, - type Publisher, - type PublisherProvider, -} from "../messagequeue.js"; - -import { - consumerInstruments, - type ConsumerInstruments, - encodeJSON, - LENGTH_KEY, - publisherInstruments, - TOPIC_KEY, - TopicCache, - type PublisherInstruments, -} from "./support.js"; - -/** Kafka config. Faithful to Go's `kafka.Config`. */ -export interface KafkaMessageQueueOptions { - brokers: string[]; - /** Consumer group id. Required for consuming; ignored by publishers. */ - groupId?: string | undefined; -} - -class KafkaPublisher implements Publisher { - readonly #producer: Producer; - readonly #topic: string; - readonly #observer: Observer; - readonly #instruments: PublisherInstruments; - #connected?: Promise; - - constructor(kafka: Kafka, topic: string, deps?: ObservabilityDeps) { - this.#producer = kafka.producer({ allowAutoTopicCreation: true }); - this.#topic = topic; - this.#observer = deps?.observer ?? makeObserver(`${topic}_publisher`, deps); - this.#instruments = publisherInstruments(deps, topic); - } - - #connect(): Promise { - this.#connected ??= this.#producer.connect(); - return this.#connected; - } - - async publish(data: unknown): Promise { - await this.#observer.run("publish", async (op) => { - op.set(TOPIC_KEY, this.#topic); - - let bytes: Uint8Array; - try { - bytes = encodeJSON(data); - } catch (err) { - this.#instruments.publishErrors.add(1); - throw op.error(err, "encoding topic message"); - } - - op.set(LENGTH_KEY, bytes.length); - - const start = performance.now(); - try { - await this.#connect(); - await this.#producer.send({ - topic: this.#topic, - messages: [{ value: Buffer.from(bytes) }], - }); - } catch (err) { - this.#instruments.publishErrors.add(1); - throw op.error(err, "publishing message"); - } - - this.#instruments.published.add(1); - this.#instruments.latency.record(performance.now() - start); - }); - } - - publishAsync(data: unknown): void { - this.publish(data).catch((err: unknown) => { - this.#observer.logger().error("publishing message", err); - }); - } - - async stop(): Promise { - if (this.#connected === undefined) { - return; - } - // Await the disconnect so kafkajs flushes any in-flight produce before we resolve. - try { - await this.#producer.disconnect(); - } catch (err) { - this.#observer.logger().error("closing kafka producer", err); - } - } -} - -/** A {@link PublisherProvider} backed by Kafka. Faithful to Go's `kafka` publisher. */ -export class KafkaPublisherProvider implements PublisherProvider { - readonly #kafka: Kafka; - readonly #deps: ObservabilityDeps | undefined; - readonly #cache = new TopicCache(); - - constructor(options: KafkaMessageQueueOptions, deps?: ObservabilityDeps) { - this.#kafka = new Kafka({ brokers: options.brokers }); - this.#deps = deps; - } - - providePublisher(topic: string): Promise { - if (topic === "") { - return Promise.reject(ErrEmptyTopicName); - } - return this.#cache.getOrBuild(topic, () => - Promise.resolve(new KafkaPublisher(this.#kafka, topic, this.#deps)), - ); - } - - // Ping checks connectivity by fetching cluster metadata from an admin client, mirroring Go's - // broker dial. - async ping(): Promise { - const admin = this.#kafka.admin(); - try { - await admin.connect(); - } finally { - await admin.disconnect(); - } - } - - async close(): Promise { - const publishers = [...this.#cache.values()]; - this.#cache.clear(); - await Promise.allSettled(publishers.map(async (p) => (await p).stop())); - } -} - -class KafkaConsumer implements Consumer { - readonly #reader: KafkaReader; - readonly #topic: string; - readonly #handler: ConsumerFunc; - readonly #observer: Observer; - readonly #instruments: ConsumerInstruments; - #stopped = false; - #resolveDone: (() => void) | undefined; - - constructor( - kafka: Kafka, - groupId: string, - topic: string, - handler: ConsumerFunc, - deps?: ObservabilityDeps, - ) { - this.#reader = kafka.consumer({ groupId }); - this.#topic = topic; - this.#handler = handler; - this.#observer = deps?.observer ?? makeObserver(`${topic}_consumer`, deps); - this.#instruments = consumerInstruments(deps, topic); - } - - /** Tears down the reader and resolves any pending {@link consume}. Idempotent. */ - #stop(): void { - if (this.#stopped) { - return; - } - this.#stopped = true; - this.#reader.disconnect().catch(() => undefined); - this.#resolveDone?.(); - } - - async consume(signal?: AbortSignal, onError?: (err: unknown) => void): Promise { - if (signal?.aborted) { - return; - } - - const done = new Promise((resolve) => { - this.#resolveDone = resolve; - }); - - // Register the abort listener BEFORE any await so an abort during connect/subscribe/run is not - // missed — otherwise the listener attaches too late and consume() hangs forever (LC-8). - signal?.addEventListener( - "abort", - () => { - this.#stop(); - }, - { once: true }, - ); - - // Surface consumer death: a non-retriable crash otherwise halts delivery silently while - // consume() stays pending and onError never fires (LC-9). Fatal crashes (no restart) also stop - // so the caller's await resolves instead of hanging. - this.#reader.on(this.#reader.events.CRASH, ({ payload }) => { - onError?.(payload.error); - if (!payload.restart) { - this.#observer.logger().error("kafka consumer crashed (fatal)", payload.error, { - topic: this.#topic, - }); - this.#stop(); - } - }); - this.#reader.on(this.#reader.events.DISCONNECT, () => { - this.#observer - .logger() - .debug("kafka consumer disconnected", { topic: this.#topic }); - }); - - // autoCommit is off so the offset advances only after the handler succeeds. On any failure we - // rethrow out of eachMessage: kafkajs leaves the offset uncommitted and redelivers, rather than - // letting a later message's commit advance past the failed one (silent loss). Redelivery is - // unbounded here — a bounded-retry/dead-letter policy (kafkajs `retry` + a dead-letter - // publisher) is a deliberate future seam. - try { - await this.#reader.connect(); - await this.#reader.subscribe({ topic: this.#topic, fromBeginning: false }); - await this.#reader.run({ - autoCommit: false, - eachMessage: async ({ topic, partition, message }) => { - const value = message.value ?? Buffer.alloc(0); - await this.#observer.run("consume_message", async (op) => { - op.set(TOPIC_KEY, topic).set(LENGTH_KEY, value.length); - op.spanOnly("partition", partition).spanOnly("offset", message.offset); - - try { - await this.#handler(new Uint8Array(value)); - this.#instruments.consumed.add(1); - } catch (err) { - this.#instruments.consumeErrors.add(1); - onError?.(err); - throw op.error(err, "handling message"); - } - - try { - await this.#reader.commitOffsets([ - { topic, partition, offset: (Number(message.offset) + 1).toString() }, - ]); - } catch (err) { - onError?.(err); - throw op.error(err, "committing message"); - } - }); - }, - }); - } catch (err) { - // connect/subscribe/run rejected (or an abort landed mid-setup) — surface and stop so the - // caller isn't left awaiting a consume() that can never make progress. - if (!this.#stopped) { - onError?.(err); - } - this.#stop(); - } - - return done; - } - - /** Disconnects the reader so provider.close() releases consumer-side broker connections. */ - async close(): Promise { - this.#stopped = true; - this.#resolveDone?.(); - await this.#reader.disconnect().catch(() => undefined); - } -} - -/** A {@link ConsumerProvider} backed by Kafka. Faithful to Go's `kafka` consumer. */ -export class KafkaConsumerProvider implements ConsumerProvider { - readonly #kafka: Kafka; - readonly #groupId: string; - readonly #deps: ObservabilityDeps | undefined; - readonly #cache = new TopicCache(); - - constructor(options: KafkaMessageQueueOptions, deps?: ObservabilityDeps) { - this.#kafka = new Kafka({ brokers: options.brokers }); - this.#groupId = options.groupId ?? ""; - this.#deps = deps; - } - - provideConsumer(topic: string, handler: ConsumerFunc): Promise { - if (topic === "") { - return Promise.reject(ErrEmptyTopicName); - } - return this.#cache.getOrBuild( - topic, - () => - Promise.resolve( - new KafkaConsumer(this.#kafka, this.#groupId, topic, handler, this.#deps), - ), - handler, - ); - } - - async close(): Promise { - const consumers = [...this.#cache.values()]; - this.#cache.clear(); - await Promise.allSettled(consumers.map(async (c) => (await c).close())); - } -} diff --git a/packages/messagequeue/src/providers/memory.ts b/packages/messagequeue/src/providers/memory.ts deleted file mode 100644 index 7aa602d..0000000 --- a/packages/messagequeue/src/providers/memory.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { - ErrEmptyTopicName, - type Consumer, - type ConsumerFunc, - type ConsumerProvider, - type Publisher, - type PublisherProvider, -} from "../messagequeue.js"; - -import { - consumerInstruments, - type ConsumerInstruments, - encodeJSON, - LENGTH_KEY, - publisherInstruments, - TOPIC_KEY, - TopicCache, - type PublisherInstruments, -} from "./support.js"; - -/** - * An in-process pub/sub broker. Not present in platform-go — it exists so the same - * {@link PublisherProvider}/{@link ConsumerProvider} contract runs with zero infrastructure in - * tests and single-process apps. A publisher and consumer built against the *same* broker exchange - * messages; the {@link provideMemoryPublisherProvider}/{@link provideMemoryConsumerProvider} - * factories default to a shared module-level broker so the config-driven factory "just works". - */ -export class MemoryBroker { - readonly #subscribers = new Map>(); - - subscribe(topic: string, handler: ConsumerFunc): () => void { - let handlers = this.#subscribers.get(topic); - if (handlers === undefined) { - handlers = new Set(); - this.#subscribers.set(topic, handlers); - } - handlers.add(handler); - - return () => { - const current = this.#subscribers.get(topic); - if (current !== undefined) { - current.delete(handler); - if (current.size === 0) { - this.#subscribers.delete(topic); - } - } - }; - } - - async publish(topic: string, data: Uint8Array): Promise { - const handlers = this.#subscribers.get(topic); - if (handlers === undefined) { - return; - } - // Snapshot so a handler that (un)subscribes mid-fan-out doesn't mutate the live set. - for (const handler of [...handlers]) { - await handler(data); - } - } -} - -/** The broker the factory wires when no explicit broker is supplied. */ -const defaultBroker = new MemoryBroker(); - -class MemoryPublisher implements Publisher { - readonly #broker: MemoryBroker; - readonly #topic: string; - readonly #observer: Observer; - readonly #instruments: PublisherInstruments; - - constructor(broker: MemoryBroker, topic: string, deps?: ObservabilityDeps) { - this.#broker = broker; - this.#topic = topic; - this.#observer = deps?.observer ?? makeObserver(`${topic}_publisher`, deps); - this.#instruments = publisherInstruments(deps, topic); - } - - async publish(data: unknown): Promise { - await this.#observer.run("publish", async (op) => { - let bytes: Uint8Array; - try { - bytes = encodeJSON(data); - } catch (err) { - this.#instruments.publishErrors.add(1); - throw op.error(err, "encoding topic message"); - } - op.set(TOPIC_KEY, this.#topic).set(LENGTH_KEY, bytes.length); - await this.#broker.publish(this.#topic, bytes); - this.#instruments.published.add(1); - }); - } - - publishAsync(data: unknown): void { - this.publish(data).catch((err: unknown) => { - this.#observer.logger().error("publishing message", err); - }); - } - - stop(): Promise { - return Promise.resolve(); - } -} - -/** A {@link PublisherProvider} backed by an in-process {@link MemoryBroker}. */ -export class MemoryPublisherProvider implements PublisherProvider { - readonly #broker: MemoryBroker; - readonly #deps: ObservabilityDeps | undefined; - readonly #cache = new TopicCache(); - - constructor(broker: MemoryBroker = defaultBroker, deps?: ObservabilityDeps) { - this.#broker = broker; - this.#deps = deps; - } - - providePublisher(topic: string): Promise { - if (topic === "") { - return Promise.reject(ErrEmptyTopicName); - } - return this.#cache.getOrBuild(topic, () => - Promise.resolve(new MemoryPublisher(this.#broker, topic, this.#deps)), - ); - } - - ping(): Promise { - return Promise.resolve(); - } - - close(): Promise { - this.#cache.clear(); - return Promise.resolve(); - } -} - -class MemoryConsumer implements Consumer { - readonly #broker: MemoryBroker; - readonly #topic: string; - readonly #handler: ConsumerFunc; - readonly #observer: Observer; - readonly #instruments: ConsumerInstruments; - - constructor( - broker: MemoryBroker, - topic: string, - handler: ConsumerFunc, - deps?: ObservabilityDeps, - ) { - this.#broker = broker; - this.#topic = topic; - this.#handler = handler; - this.#observer = deps?.observer ?? makeObserver(`${topic}_consumer`, deps); - this.#instruments = consumerInstruments(deps, topic); - } - - consume(signal?: AbortSignal, onError?: (err: unknown) => void): Promise { - if (signal?.aborted) { - return Promise.resolve(); - } - - const unsubscribe = this.#broker.subscribe(this.#topic, (data) => - this.#deliver(data, onError), - ); - - return new Promise((resolve) => { - const stop = (): void => { - unsubscribe(); - resolve(); - }; - signal?.addEventListener("abort", stop, { once: true }); - }); - } - - async #deliver(data: Uint8Array, onError?: (err: unknown) => void): Promise { - await this.#observer.run("consume_message", async (op) => { - op.set(TOPIC_KEY, this.#topic).set(LENGTH_KEY, data.length); - try { - await this.#handler(data); - this.#instruments.consumed.add(1); - } catch (err) { - this.#instruments.consumeErrors.add(1); - op.acknowledge(err, "handling message"); - onError?.(err); - } - }); - } -} - -/** A {@link ConsumerProvider} backed by an in-process {@link MemoryBroker}. */ -export class MemoryConsumerProvider implements ConsumerProvider { - readonly #broker: MemoryBroker; - readonly #deps: ObservabilityDeps | undefined; - readonly #cache = new TopicCache(); - - constructor(broker: MemoryBroker = defaultBroker, deps?: ObservabilityDeps) { - this.#broker = broker; - this.#deps = deps; - } - - provideConsumer(topic: string, handler: ConsumerFunc): Promise { - if (topic === "") { - return Promise.reject(ErrEmptyTopicName); - } - return this.#cache.getOrBuild( - topic, - () => Promise.resolve(new MemoryConsumer(this.#broker, topic, handler, this.#deps)), - handler, - ); - } - - close(): Promise { - this.#cache.clear(); - return Promise.resolve(); - } -} - -/** Builds a memory-backed {@link PublisherProvider}. Defaults to the shared module-level broker. */ -export function provideMemoryPublisherProvider( - deps?: ObservabilityDeps, - broker?: MemoryBroker, -): PublisherProvider { - return new MemoryPublisherProvider(broker ?? defaultBroker, deps); -} - -/** Builds a memory-backed {@link ConsumerProvider}. Defaults to the shared module-level broker. */ -export function provideMemoryConsumerProvider( - deps?: ObservabilityDeps, - broker?: MemoryBroker, -): ConsumerProvider { - return new MemoryConsumerProvider(broker ?? defaultBroker, deps); -} diff --git a/packages/messagequeue/src/providers/noop.ts b/packages/messagequeue/src/providers/noop.ts deleted file mode 100644 index b05b2ee..0000000 --- a/packages/messagequeue/src/providers/noop.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { - Consumer, - ConsumerProvider, - Publisher, - PublisherProvider, -} from "../messagequeue.js"; - -/** A {@link Publisher} that discards every message. */ -export class NoopPublisher implements Publisher { - publish(): Promise { - return Promise.resolve(); - } - - publishAsync(): void {} - - stop(): Promise { - return Promise.resolve(); - } -} - -/** A {@link PublisherProvider} that hands out {@link NoopPublisher}s. Go's `noop.publisherProvider`. */ -export class NoopPublisherProvider implements PublisherProvider { - providePublisher(): Promise { - return Promise.resolve(new NoopPublisher()); - } - - ping(): Promise { - return Promise.resolve(); - } - - close(): Promise { - return Promise.resolve(); - } -} - -/** A {@link Consumer} that never delivers anything and resolves only once aborted. */ -export class NoopConsumer implements Consumer { - consume(signal?: AbortSignal): Promise { - if (signal === undefined || signal.aborted) { - return Promise.resolve(); - } - return new Promise((resolve) => { - signal.addEventListener( - "abort", - () => { - resolve(); - }, - { once: true }, - ); - }); - } -} - -/** A {@link ConsumerProvider} that hands out {@link NoopConsumer}s. Go's `noop.consumerProvider`. */ -export class NoopConsumerProvider implements ConsumerProvider { - provideConsumer(): Promise { - return Promise.resolve(new NoopConsumer()); - } - - close(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/messagequeue/src/providers/pubsub.node.ts b/packages/messagequeue/src/providers/pubsub.node.ts deleted file mode 100644 index 1ac643f..0000000 --- a/packages/messagequeue/src/providers/pubsub.node.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { type Message, PubSub, type Topic } from "@google-cloud/pubsub"; -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { - ErrEmptyTopicName, - type Consumer, - type ConsumerFunc, - type ConsumerProvider, - type Publisher, - type PublisherProvider, -} from "../messagequeue.js"; - -import { - consumerInstruments, - type ConsumerInstruments, - encodeJSON, - LENGTH_KEY, - publisherInstruments, - TOPIC_KEY, - TopicCache, - type PublisherInstruments, -} from "./support.js"; - -/** Pub/Sub config. Faithful to Go's `pubsub.Config`. */ -export interface PubSubMessageQueueOptions { - projectId: string; -} - -/** - * Derives a subscription name from a topic name. Mirrors Go's `subscriptionNameForTopic`: a fully - * qualified `projects/{p}/topics/{t}` becomes `projects/{p}/subscriptions/{t}`, and a short topic - * name is used verbatim as the subscription id. - */ -function subscriptionNameForTopic(topic: string): string { - return topic.replace("/topics/", "/subscriptions/"); -} - -class PubSubPublisher implements Publisher { - readonly #topic: Topic; - readonly #topicName: string; - readonly #observer: Observer; - readonly #instruments: PublisherInstruments; - - constructor(topic: Topic, topicName: string, deps?: ObservabilityDeps) { - this.#topic = topic; - this.#topicName = topicName; - this.#observer = deps?.observer ?? makeObserver(`${topicName}_publisher`, deps); - this.#instruments = publisherInstruments(deps, topicName); - } - - async publish(data: unknown): Promise { - await this.#observer.run("publish", async (op) => { - let bytes: Uint8Array; - try { - bytes = encodeJSON(data); - } catch (err) { - this.#instruments.publishErrors.add(1); - throw op.error(err, "encoding topic message"); - } - - op.set(TOPIC_KEY, this.#topicName).set(LENGTH_KEY, bytes.length); - - const start = performance.now(); - let messageId: string; - try { - messageId = await this.#topic.publishMessage({ data: Buffer.from(bytes) }); - } catch (err) { - this.#instruments.publishErrors.add(1); - throw op.error(err, "publishing pubsub message"); - } - - op.spanOnly("message_id", messageId); - this.#instruments.published.add(1); - this.#instruments.latency.record(performance.now() - start); - }); - } - - publishAsync(data: unknown): void { - this.publish(data).catch((err: unknown) => { - this.#observer.logger().error("publishing message", err); - }); - } - - async stop(): Promise { - // Await the flush of any batched-but-unsent messages so they are not dropped on shutdown; - // mirrors Go's `pubSubPublisher.Stop`, now awaitable. - try { - await this.#topic.flush(); - } catch (err) { - this.#observer.logger().error("flushing pubsub topic on stop", err); - } - } -} - -/** A {@link PublisherProvider} backed by GCP Pub/Sub. Faithful to Go's `pubsub` publisher. */ -export class PubSubPublisherProvider implements PublisherProvider { - readonly #client: PubSub; - readonly #deps: ObservabilityDeps | undefined; - readonly #cache = new TopicCache(); - - constructor(options: PubSubMessageQueueOptions, deps?: ObservabilityDeps) { - this.#client = new PubSub({ projectId: options.projectId }); - this.#deps = deps; - } - - providePublisher(topic: string): Promise { - if (topic === "") { - return Promise.reject(ErrEmptyTopicName); - } - return this.#cache.getOrBuild(topic, () => - Promise.resolve(new PubSubPublisher(this.#client.topic(topic), topic, this.#deps)), - ); - } - - // Ping is a no-op for GCP Pub/Sub (managed service), mirroring Go. - ping(): Promise { - return Promise.resolve(); - } - - async close(): Promise { - // Flush every cached publisher's batched messages before tearing the client down, so a - // shutdown does not drop buffered sends. - const publishers = [...this.#cache.values()]; - this.#cache.clear(); - await Promise.allSettled(publishers.map(async (p) => (await p).stop())); - await this.#client.close(); - } -} - -class PubSubConsumer implements Consumer { - readonly #client: PubSub; - readonly #topic: string; - readonly #handler: ConsumerFunc; - readonly #observer: Observer; - readonly #instruments: ConsumerInstruments; - - constructor( - client: PubSub, - topic: string, - handler: ConsumerFunc, - deps?: ObservabilityDeps, - ) { - this.#client = client; - this.#topic = topic; - this.#handler = handler; - this.#observer = deps?.observer ?? makeObserver(`${topic}_consumer`, deps); - this.#instruments = consumerInstruments(deps, topic); - } - - async consume(signal?: AbortSignal, onError?: (err: unknown) => void): Promise { - if (signal?.aborted) { - return; - } - - const subscription = this.#client.subscription(subscriptionNameForTopic(this.#topic)); - - subscription.on("message", (message: Message) => { - void this.#deliver(message, onError); - }); - subscription.on("error", (err: unknown) => { - this.#observer.logger().error(`receiving ${this.#topic} pub/sub data`, err); - onError?.(err); - }); - - return new Promise((resolve) => { - const stop = (): void => { - subscription.close().catch(() => undefined); - resolve(); - }; - signal?.addEventListener("abort", stop, { once: true }); - }); - } - - async #deliver(message: Message, onError?: (err: unknown) => void): Promise { - await this.#observer.run("consume_message", async (op) => { - op.set(TOPIC_KEY, this.#topic).set(LENGTH_KEY, message.data.length); - op.spanOnly("message_id", message.id).spanOnly( - "delivery_attempt", - message.deliveryAttempt, - ); - try { - await this.#handler(new Uint8Array(message.data)); - this.#instruments.consumed.add(1); - message.ack(); - } catch (err) { - this.#instruments.consumeErrors.add(1); - op.acknowledge(err, "handling pubsub message"); - message.nack(); - onError?.(err); - } - }); - } -} - -/** A {@link ConsumerProvider} backed by GCP Pub/Sub. Faithful to Go's `pubsub` consumer. */ -export class PubSubConsumerProvider implements ConsumerProvider { - readonly #client: PubSub; - readonly #deps: ObservabilityDeps | undefined; - readonly #cache = new TopicCache(); - - constructor(options: PubSubMessageQueueOptions, deps?: ObservabilityDeps) { - this.#client = new PubSub({ projectId: options.projectId }); - this.#deps = deps; - } - - provideConsumer(topic: string, handler: ConsumerFunc): Promise { - if (topic === "") { - return Promise.reject(ErrEmptyTopicName); - } - return this.#cache.getOrBuild( - topic, - () => Promise.resolve(new PubSubConsumer(this.#client, topic, handler, this.#deps)), - handler, - ); - } - - async close(): Promise { - this.#cache.clear(); - await this.#client.close(); - } -} diff --git a/packages/messagequeue/src/providers/redis.node.ts b/packages/messagequeue/src/providers/redis.node.ts deleted file mode 100644 index 355ec61..0000000 --- a/packages/messagequeue/src/providers/redis.node.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import { Cluster, Redis } from "ioredis"; - -import { - ErrEmptyTopicName, - type Consumer, - type ConsumerFunc, - type ConsumerProvider, - type Publisher, - type PublisherProvider, -} from "../messagequeue.js"; - -import { - consumerInstruments, - type ConsumerInstruments, - encodeJSON, - LENGTH_KEY, - publisherInstruments, - TOPIC_KEY, - TopicCache, - type PublisherInstruments, -} from "./support.js"; - -/** Redis connection config. Faithful to Go's `redis.Config`. */ -export interface RedisMessageQueueOptions { - /** One or more `host:port` addresses. Multiple addresses select a cluster client. */ - queueAddresses: string[]; - username?: string | undefined; - password?: string | undefined; -} - -/** The subset of the ioredis surface both {@link Redis} and {@link Cluster} share that we use. */ -interface PubSubConn { - publish(channel: string, message: Buffer): Promise; - subscribe(...channels: string[]): Promise; - unsubscribe(...channels: string[]): Promise; - ping(): Promise; - quit(): Promise; - disconnect(): void; - on( - event: "messageBuffer", - listener: (channel: Buffer, message: Buffer) => void, - ): unknown; -} - -/** Parses a Redis `host[:port]` address, including bracketed and bare IPv6. Exported for testing. */ -export function parseAddress(address: string): { host: string; port: number } { - // Bracketed IPv6, optionally with a port: `[::1]` or `[::1]:6379`. Brackets are stripped from - // the host since ioredis expects the bare literal. - if (address.startsWith("[")) { - const end = address.indexOf("]"); - if (end !== -1) { - const host = address.slice(1, end); - const rest = address.slice(end + 1); - const port = rest.startsWith(":") ? Number(rest.slice(1)) || 6379 : 6379; - return { host, port }; - } - } - const first = address.indexOf(":"); - // No colon (bare host) or more than one colon (an unbracketed IPv6 literal with no port) both - // mean the whole string is the host and the default port applies. A single colon is host:port. - if (first === -1 || first !== address.lastIndexOf(":")) { - return { host: address, port: 6379 }; - } - return { - host: address.slice(0, first), - port: Number(address.slice(first + 1)) || 6379, - }; -} - -/** Builds a single-node or cluster client from the configured addresses. */ -function createRedisClient(options: RedisMessageQueueOptions): PubSubConn { - const { username, password } = options; - const nodes = options.queueAddresses.map(parseAddress); - - if (nodes.length > 1) { - return new Cluster(nodes, { - redisOptions: { username, password, lazyConnect: true }, - }); - } - - const node = nodes[0] ?? { host: "localhost", port: 6379 }; - return new Redis({ - host: node.host, - port: node.port, - username, - password, - lazyConnect: true, - }); -} - -class RedisPublisher implements Publisher { - readonly #client: PubSubConn; - readonly #topic: string; - readonly #observer: Observer; - readonly #instruments: PublisherInstruments; - - constructor(client: PubSubConn, topic: string, deps?: ObservabilityDeps) { - this.#client = client; - this.#topic = topic; - this.#observer = deps?.observer ?? makeObserver(`${topic}_publisher`, deps); - this.#instruments = publisherInstruments(deps, topic); - } - - async publish(data: unknown): Promise { - await this.#observer.run("publish", async (op) => { - let bytes: Uint8Array; - try { - bytes = encodeJSON(data); - } catch (err) { - this.#instruments.publishErrors.add(1); - throw op.error(err, "encoding topic message"); - } - - op.set(TOPIC_KEY, this.#topic).set(LENGTH_KEY, bytes.length); - - const start = performance.now(); - try { - await this.#client.publish(this.#topic, Buffer.from(bytes)); - } catch (err) { - this.#instruments.publishErrors.add(1); - throw op.error(err, "publishing message"); - } - - this.#instruments.published.add(1); - this.#instruments.latency.record(performance.now() - start); - }); - } - - publishAsync(data: unknown): void { - this.publish(data).catch((err: unknown) => { - this.#observer.logger().error("publishing message", err); - }); - } - - // The publishing connection is owned by the provider, which closes it; a per-publisher stop - // would tear the shared client out from under sibling publishers. - stop(): Promise { - return Promise.resolve(); - } -} - -/** A {@link PublisherProvider} backed by Redis PUBLISH. Faithful to Go's `redis` publisher. */ -export class RedisPublisherProvider implements PublisherProvider { - readonly #client: PubSubConn; - readonly #deps: ObservabilityDeps | undefined; - readonly #cache = new TopicCache(); - - constructor(options: RedisMessageQueueOptions, deps?: ObservabilityDeps) { - this.#client = createRedisClient(options); - this.#deps = deps; - } - - providePublisher(topic: string): Promise { - if (topic === "") { - return Promise.reject(ErrEmptyTopicName); - } - return this.#cache.getOrBuild(topic, () => - Promise.resolve(new RedisPublisher(this.#client, topic, this.#deps)), - ); - } - - async ping(): Promise { - await this.#client.ping(); - } - - async close(): Promise { - this.#cache.clear(); - await this.#client.quit().catch(() => { - this.#client.disconnect(); - }); - } -} - -class RedisConsumer implements Consumer { - readonly #options: RedisMessageQueueOptions; - readonly #topic: string; - readonly #handler: ConsumerFunc; - readonly #observer: Observer; - readonly #instruments: ConsumerInstruments; - #sub: PubSubConn | undefined; - #stopped = false; - #resolveDone: (() => void) | undefined; - // Serializes handler delivery: each incoming message is chained onto the previous one so a flood - // can never spawn unbounded concurrent handlers (LC-11). - #tail: Promise = Promise.resolve(); - - constructor( - options: RedisMessageQueueOptions, - topic: string, - handler: ConsumerFunc, - deps?: ObservabilityDeps, - ) { - this.#options = options; - this.#topic = topic; - this.#handler = handler; - this.#observer = deps?.observer ?? makeObserver(`${topic}_consumer`, deps); - this.#instruments = consumerInstruments(deps, topic); - } - - /** Fire-and-forget teardown (the abort path); {@link close} is the awaitable variant. */ - #stop(): void { - if (this.#stopped) { - return; - } - this.#stopped = true; - const sub = this.#sub; - if (sub !== undefined) { - sub.unsubscribe(this.#topic).catch(() => undefined); - sub.quit().catch(() => { - sub.disconnect(); - }); - } - this.#resolveDone?.(); - } - - async consume(signal?: AbortSignal, onError?: (err: unknown) => void): Promise { - if (signal?.aborted) { - return; - } - - const done = new Promise((resolve) => { - this.#resolveDone = resolve; - }); - - // Register the abort listener BEFORE the SUBSCRIBE await so an abort landing mid-subscribe is - // not missed — otherwise consume() would hang forever (LC-8). - signal?.addEventListener( - "abort", - () => { - this.#stop(); - }, - { once: true }, - ); - - // A dedicated connection: once a Redis connection subscribes it enters subscriber mode and - // can no longer issue ordinary commands, so the consumer never shares the publisher's client. - const sub = createRedisClient(this.#options); - this.#sub = sub; - - sub.on("messageBuffer", (_channel, message) => { - this.#tail = this.#tail.then(() => this.#deliver(message, onError)); - }); - - // Block until Redis confirms the SUBSCRIBE, mirroring Go's `subscription.Receive`: without - // it a publisher racing us would silently drop the first message, since Redis pub/sub does - // not buffer for late subscribers. - try { - await sub.subscribe(this.#topic); - } catch (err) { - onError?.(err); - this.#stop(); - return done; - } - this.#observer.logger().debug("subscribed to topic", { topic: this.#topic }); - - return done; - } - - /** Awaitable teardown for provider.close(): drains in-flight delivery, then quits the socket. */ - async close(): Promise { - const alreadyStopped = this.#stopped; - this.#stopped = true; - this.#resolveDone?.(); - await this.#tail.catch(() => undefined); - if (alreadyStopped) { - return; - } - const sub = this.#sub; - if (sub !== undefined) { - await sub.unsubscribe(this.#topic).catch(() => undefined); - await sub.quit().catch(() => { - sub.disconnect(); - }); - } - } - - async #deliver(message: Buffer, onError?: (err: unknown) => void): Promise { - await this.#observer.run("consume_message", async (op) => { - op.set(TOPIC_KEY, this.#topic).set(LENGTH_KEY, message.length); - try { - await this.#handler(new Uint8Array(message)); - this.#instruments.consumed.add(1); - } catch (err) { - this.#instruments.consumeErrors.add(1); - op.acknowledge(err, "handling message"); - onError?.(err); - } - }); - } -} - -/** A {@link ConsumerProvider} backed by Redis SUBSCRIBE. Faithful to Go's `redis` consumer. */ -export class RedisConsumerProvider implements ConsumerProvider { - readonly #options: RedisMessageQueueOptions; - readonly #deps: ObservabilityDeps | undefined; - readonly #cache = new TopicCache(); - - constructor(options: RedisMessageQueueOptions, deps?: ObservabilityDeps) { - this.#options = options; - this.#deps = deps; - } - - provideConsumer(topic: string, handler: ConsumerFunc): Promise { - if (topic === "") { - return Promise.reject(ErrEmptyTopicName); - } - return this.#cache.getOrBuild( - topic, - () => Promise.resolve(new RedisConsumer(this.#options, topic, handler, this.#deps)), - handler, - ); - } - - async close(): Promise { - // Each consumer owns its own subscriber socket (created inside consume()); close them all so a - // subscribing consumer's connection is released rather than leaking. - const consumers = [...this.#cache.values()]; - this.#cache.clear(); - await Promise.allSettled(consumers.map(async (c) => (await c).close())); - } -} diff --git a/packages/messagequeue/src/providers/sqs.node.ts b/packages/messagequeue/src/providers/sqs.node.ts deleted file mode 100644 index 0acb948..0000000 --- a/packages/messagequeue/src/providers/sqs.node.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { - DeleteMessageCommand, - ReceiveMessageCommand, - SendMessageCommand, - SQSClient, - type SQSClientConfig, -} from "@aws-sdk/client-sqs"; -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { - ErrEmptyTopicName, - type Consumer, - type ConsumerFunc, - type ConsumerProvider, - type Publisher, - type PublisherProvider, -} from "../messagequeue.js"; - -import { - consumerInstruments, - type ConsumerInstruments, - LENGTH_KEY, - publisherInstruments, - TOPIC_KEY, - TopicCache, - type PublisherInstruments, -} from "./support.js"; - -const LONG_POLL_WAIT_SECONDS = 20; -const MAX_NUMBER_OF_MESSAGES = 10; - -/** Backoff bounds for consecutive receive failures — a bad queue URL/IAM error must not hot-loop. */ -const RECEIVE_BACKOFF_BASE_MS = 100; -const RECEIVE_BACKOFF_MAX_MS = 30_000; - -/** Full-jitter exponential backoff for the Nth (1-based) consecutive failure. */ -function receiveBackoffMs(failures: number): number { - const ceiling = Math.min( - RECEIVE_BACKOFF_MAX_MS, - RECEIVE_BACKOFF_BASE_MS * 2 ** (failures - 1), - ); - return Math.random() * ceiling; -} - -/** Sleeps `ms`, resolving early (never rejecting) if `signal` aborts first. */ -function abortableSleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve) => { - if (signal?.aborted === true) { - resolve(); - return; - } - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - const onAbort = (): void => { - clearTimeout(timer); - resolve(); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} - -/** - * SQS config. Faithful to Go's `sqs.Config`, whose provider otherwise reads AWS credentials and - * region from the ambient default chain. `region`/`endpoint`/`credentials` are exposed so a - * caller (or a LocalStack test) can override that chain explicitly. - */ -export interface SQSMessageQueueOptions { - region?: string | undefined; - /** Override the SQS endpoint — e.g. a LocalStack URL for tests. */ - endpoint?: string | undefined; - credentials?: - | { accessKeyId: string; secretAccessKey: string; sessionToken?: string | undefined } - | undefined; -} - -function newClient(options: SQSMessageQueueOptions): SQSClient { - const config: SQSClientConfig = {}; - if (options.region !== undefined) { - config.region = options.region; - } - if (options.endpoint !== undefined) { - config.endpoint = options.endpoint; - } - if (options.credentials !== undefined) { - const { accessKeyId, secretAccessKey, sessionToken } = options.credentials; - config.credentials = { - accessKeyId, - secretAccessKey, - ...(sessionToken === undefined ? {} : { sessionToken }), - }; - } - return new SQSClient(config); -} - -class SQSPublisher implements Publisher { - readonly #client: SQSClient; - readonly #queueURL: string; - readonly #observer: Observer; - readonly #instruments: PublisherInstruments; - - constructor(client: SQSClient, queueURL: string, deps?: ObservabilityDeps) { - this.#client = client; - this.#queueURL = queueURL; - this.#observer = deps?.observer ?? makeObserver(`${queueURL}_publisher`, deps); - this.#instruments = publisherInstruments(deps, queueURL); - } - - async publish(data: unknown): Promise { - await this.#observer.run("publish", async (op) => { - op.set(TOPIC_KEY, this.#queueURL); - - let body: string; - try { - body = JSON.stringify(data ?? null); - } catch (err) { - this.#instruments.publishErrors.add(1); - throw op.error(err, "encoding topic message"); - } - - op.set(LENGTH_KEY, body.length); - - const start = performance.now(); - try { - await this.#client.send( - new SendMessageCommand({ QueueUrl: this.#queueURL, MessageBody: body }), - ); - } catch (err) { - this.#instruments.publishErrors.add(1); - throw op.error(err, "publishing message"); - } - - this.#instruments.published.add(1); - this.#instruments.latency.record(performance.now() - start); - }); - } - - publishAsync(data: unknown): void { - this.publish(data).catch((err: unknown) => { - this.#observer.logger().error("publishing message", err); - }); - } - - // SQS is a managed service with no per-publisher connection to release. - stop(): Promise { - return Promise.resolve(); - } -} - -/** A {@link PublisherProvider} backed by Amazon SQS. Faithful to Go's `sqs` publisher. */ -export class SQSPublisherProvider implements PublisherProvider { - readonly #client: SQSClient; - readonly #deps: ObservabilityDeps | undefined; - readonly #cache = new TopicCache(); - - constructor(options: SQSMessageQueueOptions = {}, deps?: ObservabilityDeps) { - this.#client = newClient(options); - this.#deps = deps; - } - - providePublisher(topic: string): Promise { - if (topic === "") { - return Promise.reject(ErrEmptyTopicName); - } - return this.#cache.getOrBuild(topic, () => - Promise.resolve(new SQSPublisher(this.#client, topic, this.#deps)), - ); - } - - // Ping is a no-op for SQS (SQS is a managed service), mirroring Go. - ping(): Promise { - return Promise.resolve(); - } - - close(): Promise { - this.#cache.clear(); - this.#client.destroy(); - return Promise.resolve(); - } -} - -class SQSConsumer implements Consumer { - readonly #client: SQSClient; - readonly #queueURL: string; - readonly #handler: ConsumerFunc; - readonly #observer: Observer; - readonly #instruments: ConsumerInstruments; - - constructor( - client: SQSClient, - queueURL: string, - handler: ConsumerFunc, - deps?: ObservabilityDeps, - ) { - this.#client = client; - this.#queueURL = queueURL; - this.#handler = handler; - this.#observer = deps?.observer ?? makeObserver(`${queueURL}_consumer`, deps); - this.#instruments = consumerInstruments(deps, queueURL); - } - - /** - * Long-polls the queue and processes messages until `signal` aborts. On handler success the - * message is deleted; on failure it is left to reappear after its visibility timeout. - */ - async consume(signal?: AbortSignal, onError?: (err: unknown) => void): Promise { - const aborted = (): boolean => signal?.aborted === true; - // Consecutive receive failures back off exponentially so a persistent error (bad queue URL, - // denied IAM) can't spin a tight error loop; a successful receive resets the counter. - let consecutiveFailures = 0; - - for (;;) { - if (aborted()) { - return; - } - - let messages; - try { - const output = await this.#client.send( - new ReceiveMessageCommand({ - QueueUrl: this.#queueURL, - MaxNumberOfMessages: MAX_NUMBER_OF_MESSAGES, - WaitTimeSeconds: LONG_POLL_WAIT_SECONDS, - }), - signal === undefined ? undefined : { abortSignal: signal }, - ); - messages = output.Messages ?? []; - consecutiveFailures = 0; - } catch (err) { - if (aborted()) { - return; - } - consecutiveFailures += 1; - this.#observer - .logger() - .error("receiving SQS messages", err, { consecutiveFailures }); - onError?.(err); - await abortableSleep(receiveBackoffMs(consecutiveFailures), signal); - continue; - } - - for (const message of messages) { - if (message.Body === undefined) { - continue; - } - await this.#deliver( - message.Body, - message.ReceiptHandle, - message.MessageId, - onError, - ); - } - } - } - - async #deliver( - body: string, - receiptHandle: string | undefined, - messageId: string | undefined, - onError?: (err: unknown) => void, - ): Promise { - const bytes = new TextEncoder().encode(body); - await this.#observer.run("consume_message", async (op) => { - op.set(TOPIC_KEY, this.#queueURL).set(LENGTH_KEY, bytes.length); - if (messageId !== undefined) { - op.spanOnly("message_id", messageId); - } - try { - await this.#handler(bytes); - this.#instruments.consumed.add(1); - } catch (err) { - this.#instruments.consumeErrors.add(1); - op.acknowledge(err, "handling SQS message"); - onError?.(err); - return; - } - - try { - await this.#client.send( - new DeleteMessageCommand({ - QueueUrl: this.#queueURL, - ReceiptHandle: receiptHandle, - }), - ); - } catch (err) { - op.acknowledge(err, "deleting SQS message"); - onError?.(err); - } - }); - } -} - -/** A {@link ConsumerProvider} backed by Amazon SQS. Faithful to Go's `sqs` consumer. */ -export class SQSConsumerProvider implements ConsumerProvider { - readonly #client: SQSClient; - readonly #deps: ObservabilityDeps | undefined; - readonly #cache = new TopicCache(); - - constructor(options: SQSMessageQueueOptions = {}, deps?: ObservabilityDeps) { - this.#client = newClient(options); - this.#deps = deps; - } - - provideConsumer(topic: string, handler: ConsumerFunc): Promise { - if (topic === "") { - return Promise.reject(ErrEmptyTopicName); - } - return this.#cache.getOrBuild( - topic, - () => Promise.resolve(new SQSConsumer(this.#client, topic, handler, this.#deps)), - handler, - ); - } - - close(): Promise { - this.#cache.clear(); - this.#client.destroy(); - return Promise.resolve(); - } -} diff --git a/packages/messagequeue/src/providers/support.ts b/packages/messagequeue/src/providers/support.ts deleted file mode 100644 index d1a8e32..0000000 --- a/packages/messagequeue/src/providers/support.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { - makeMetrics, - type Metrics, - type ObservabilityDeps, -} from "@primandproper/observability"; - -import { ErrConsumerHandlerMismatch } from "../messagequeue.js"; - -/** The name every provider registers its logger, tracer, and meter under. */ -export const o11yName = "messagequeue"; - -type Counter = ReturnType; -type Histogram = ReturnType; - -/** Span/log attribute keys, mirroring Go's `observability/keys`. */ -export const TOPIC_KEY = "topic"; -export const LENGTH_KEY = "length"; - -/** The per-topic publish instruments Go mints in every `provide*Publisher`. */ -export interface PublisherInstruments { - published: Counter; - publishErrors: Counter; - latency: Histogram; -} - -/** Builds the `{topic}_published` / `_publish_errors` / `_publish_latency_ms` instruments. */ -export function publisherInstruments( - deps: ObservabilityDeps | undefined, - topic: string, -): PublisherInstruments { - const metrics = makeMetrics(o11yName, deps?.metrics); - return { - published: metrics.counter(`${topic}_published`), - publishErrors: metrics.counter(`${topic}_publish_errors`), - latency: metrics.histogram(`${topic}_publish_latency_ms`, { unit: "ms" }), - }; -} - -/** The per-topic consume instruments minted in every `provide*Consumer`. */ -export interface ConsumerInstruments { - /** Incremented once per message that the handler processed successfully. */ - consumed: Counter; - /** Incremented once per message whose handler threw, so a failing consumer is visible. */ - consumeErrors: Counter; -} - -/** - * Builds the `{topic}_consumed` / `{topic}_consume_errors` counters. `consumed` counts only - * *successful* handler runs and `consumeErrors` counts failures, so a 100%-failing consumer no - * longer looks healthy the way a pre-handler increment made it (MQ-3). - */ -export function consumerInstruments( - deps: ObservabilityDeps | undefined, - topic: string, -): ConsumerInstruments { - const metrics = makeMetrics(o11yName, deps?.metrics); - return { - consumed: metrics.counter(`${topic}_consumed`), - consumeErrors: metrics.counter(`${topic}_consume_errors`), - }; -} - -/** - * JSON-encodes publish payloads to bytes — the analogue of Go's - * `encoding.ProvideClientEncoder(ContentTypeJSON)`. Kept as bytes so every provider hands the - * same wire form to its broker. - */ -export function encodeJSON(data: unknown): Uint8Array { - return new TextEncoder().encode(JSON.stringify(data ?? null)); -} - -/** - * Per-topic cache shared by every provider. Go guards an identical `map[string]T` with a mutex; - * JavaScript's single-threaded model makes the lock unnecessary, but concurrent async callers can - * still race between the miss check and the insert, so the builder result is memoized as a promise - * and awaited under one in-flight build per topic. - */ -export class TopicCache { - readonly #entries = new Map>(); - // The identity a cached entry was built for (a consumer's handler). Lets a repeat call for the - // same topic detect a *different* identity and reject rather than silently returning the first. - readonly #identities = new Map(); - - /** - * Returns the cached value for `topic`, building it on first use. When `identity` is given (the - * consumer's handler), a later call for the same topic with a *different* identity rejects with - * {@link ErrConsumerHandlerMismatch} instead of silently returning the original — see MQ-2. - * Publishers omit `identity`, so they keep plain topic-keyed memoization. - */ - async getOrBuild( - topic: string, - build: () => Promise, - identity?: unknown, - ): Promise { - const existing = this.#entries.get(topic); - if (existing !== undefined) { - if (identity !== undefined && this.#identities.get(topic) !== identity) { - throw ErrConsumerHandlerMismatch; - } - return existing; - } - const built = build(); - this.#entries.set(topic, built); - if (identity !== undefined) { - this.#identities.set(topic, identity); - } - try { - return await built; - } catch (err) { - // A failed build must not poison the cache — drop it so the next call retries. - this.#entries.delete(topic); - this.#identities.delete(topic); - throw err; - } - } - - values(): IterableIterator> { - return this.#entries.values(); - } - - clear(): void { - this.#entries.clear(); - this.#identities.clear(); - } -} diff --git a/packages/messagequeue/tsconfig.json b/packages/messagequeue/tsconfig.json deleted file mode 100644 index bdb9327..0000000 --- a/packages/messagequeue/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022"], - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/messagequeue/tsup.config.ts b/packages/messagequeue/tsup.config.ts deleted file mode 100644 index 8bf2394..0000000 --- a/packages/messagequeue/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { index: "src/index.ts" }, - platform: "node", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: true, -}); diff --git a/packages/notifications/CHANGELOG.md b/packages/notifications/CHANGELOG.md deleted file mode 100644 index 39aabf6..0000000 --- a/packages/notifications/CHANGELOG.md +++ /dev/null @@ -1,49 +0,0 @@ -# @primandproper/notifications - -## 0.2.0 - -### Minor Changes - -- a124406: Remove the `ErrPlatformNotSupported` export (replaced by `newPlatformNotSupportedError()` and `PLATFORM_NOT_SUPPORTED_MESSAGE`) and add a required `close()` to `PushNotificationSender`. Breaking for importers and implementers. The `secure` config default is now `true`. - -### Patch Changes - -- Updated dependencies [a124406] -- Updated dependencies [a124406] - - @primandproper/errors@0.0.2 - - @primandproper/observability@0.1.0 - -## 0.1.0 - -### Minor Changes - -- db7c3ec: rewrite `notifications` as a faithful port of `platform-go/notifications`. **Breaking:** the previous - isomorphic client-subscriber (`NotificationClient` with `subscribe`/`onNotification`/`connect` over a - browser WebSocket, plus `memory`/`websocket`/`noop` providers) is replaced by Go's actual server-side - shape. The package is now **server-only**. - - Two concerns, mirroring Go's `async` and `mobile` subpackages: - - - **Async notifier** — `AsyncNotifier.publish(channel, event)` / `close()` with an `AsyncEvent` - (`{ type, data? }`). Providers: **pusher** (`pusher` SDK), **ably** (`ably` REST), and **noop**. - Both vendor providers expose Go's testability seams (`PusherClient` / `ChannelPublisher`) so they - unit-test against injected fakes. `provideAsyncNotifier(config, deps)` mirrors Go's - `ProvideAsyncNotifier`. - - **Mobile push** — `PushNotificationSender.sendPush(platform, token, msg)` with `PushMessage` and - the `MobileNotificationRequest` DTO. `MultiPlatformPushSender` routes `ios`→APNs - (`@parse/node-apn`) and `android`→FCM (`firebase-admin`), each behind an injectable client seam; - `ErrPlatformNotSupported` mirrors Go's sentinel. `providePushSender(config, deps)` mirrors Go's - `ProvidePushSender`, initializing each platform independently and falling back to noop when neither - is available. - - Observability is wired to the same depth as Go: every notifier/sender holds an `Observer`, wraps - each publish/send in `observer.run(...)` with the same span attributes (`channel`, `platform`, - `event.type`, `apnsID`/`reason`, `fcm.message_id`), records failures via `op.error`, and mints the - `{o11yName}_sends` / `_errors` counters Go creates with `NewInt64Counter`. o11y names match Go - (`async_notifications_pusher`, `async_notifications_ably`, `ios_notif_sender`, - `android_notif_sender`, `mobile_push_sender`). - - **Out of scope:** the `websocket` and `sse` async notifiers. They require server-side connection - management + an HTTP-request upgrade, which the server framework owns (the same reason `routing` and - `server` are not ported). The `ConnectionAcceptor` interface is defined for parity, and the two - provider values are accepted by the config schema but rejected by the factory with a clear error. diff --git a/packages/notifications/package.json b/packages/notifications/package.json deleted file mode 100644 index d0d3a12..0000000 --- a/packages/notifications/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@primandproper/notifications", - "version": "0.2.0", - "type": "module", - "description": "Server-only notifications: an AsyncNotifier publisher and a mobile PushNotificationSender with swappable providers.", - "license": "AGPL-3.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/primandproper/platform-ts.git", - "directory": "packages/notifications" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "eslint .", - "clean": "rm -rf dist .turbo" - }, - "dependencies": { - "@parse/node-apn": "8.1.0", - "@primandproper/errors": "workspace:*", - "@primandproper/observability": "workspace:*", - "ably": "2.23.0", - "firebase-admin": "14.1.0", - "pusher": "5.3.4", - "zod": "3.25.76" - } -} diff --git a/packages/notifications/src/async.ts b/packages/notifications/src/async.ts deleted file mode 100644 index ac83004..0000000 --- a/packages/notifications/src/async.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * The async-notification contract, ported from platform-go's `notifications/async`. An - * {@link AsyncNotifier} *publishes* events to named channels; delivery to end clients is the - * backend's job (Pusher, Ably, or — in Go — server-managed WebSocket/SSE connections). - */ - -/** An async notification event published to a channel. Go's `async.Event`. */ -export interface AsyncEvent { - /** Application-defined discriminator for the payload. */ - type: string; - /** Optional opaque payload; providers forward it verbatim to the backend. */ - data?: unknown; -} - -/** - * Publishes events to named channels. Implementations may deliver via Pusher, Ably, or other - * backends. Faithful to Go's `async.AsyncNotifier`. - */ -export interface AsyncNotifier { - /** Sends an event to all subscribers of the given channel. */ - publish(channel: string, event: AsyncEvent): Promise; - /** Releases resources held by the notifier. */ - close(): void; -} - -/** - * Optional interface for backends that manage server-side client connections (WebSocket, SSE). - * Faithful to Go's `async.ConnectionAcceptor`, kept for interface parity. - * - * No provider in this package implements it: accepting an inbound connection requires upgrading - * a live HTTP request, which is owned by the server framework (Fastify/Hono/`ws`) — the same - * reason `routing`/`server` are out of scope for platform-ts. A WebSocket/SSE notifier would - * plug a framework's upgrade handle into this seam. - */ -export interface ConnectionAcceptor { - /** - * Upgrades an inbound request to a persistent connection registered under `channel` and - * `memberID`. `request`/`response` are intentionally `unknown` — the concrete types belong to - * whichever HTTP framework owns the connection. - */ - acceptConnection( - request: unknown, - response: unknown, - channel: string, - memberID: string, - ): Promise; -} - -/** Returned when a provider is constructed without its required config. Go's `ErrNilConfig`. */ -export const ErrNilConfig = new Error("async notifier config is nil"); diff --git a/packages/notifications/src/config.ts b/packages/notifications/src/config.ts deleted file mode 100644 index 3274e33..0000000 --- a/packages/notifications/src/config.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { z } from "zod"; - -/** Pusher async notifier config. Faithful to Go's `pusher.Config`. */ -export const PusherConfigSchema = z.object({ - appID: z.string().min(1), - key: z.string().min(1), - secret: z.string().min(1), - cluster: z.string().min(1), - /** Use TLS for the Pusher API connection. Secure by default; set `false` only for local dev. */ - secure: z.boolean().default(true), -}); -export type PusherConfig = z.infer; - -/** Ably async notifier config. Faithful to Go's `ably.Config`. */ -export const AblyConfigSchema = z.object({ - apiKey: z.string().min(1), -}); -export type AblyConfig = z.infer; - -/** - * Async-notifier providers. `pusher`, `ably`, and `noop` are implemented. `websocket` and `sse` - * are accepted for parity with Go's provider set but are out of scope in platform-ts — they - * require server-side connection management + HTTP upgrade, which the server framework owns (see - * `ConnectionAcceptor`); the factory rejects them with a clear error. - */ -export const ASYNC_NOTIFIER_PROVIDERS = [ - "pusher", - "ably", - "websocket", - "sse", - "noop", -] as const; - -/** - * Selects and configures an {@link import("./async.js").AsyncNotifier}. Replaces the Go - * `env:`-tagged `Config` + ozzo `ValidateWithContext`; the block for the selected provider is - * required (mirrors Go's `validation.When`). - */ -export const AsyncNotifierConfigSchema = z - .object({ - provider: z.enum(ASYNC_NOTIFIER_PROVIDERS).default("noop"), - pusher: PusherConfigSchema.optional(), - ably: AblyConfigSchema.optional(), - }) - .superRefine((cfg, ctx) => { - const required: Partial> = - { - pusher: cfg.pusher, - ably: cfg.ably, - }; - if (cfg.provider in required && required[cfg.provider] === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [cfg.provider], - message: `${cfg.provider} config is required when provider is '${cfg.provider}'`, - }); - } - }); -export type AsyncNotifierConfig = z.infer; -export type AsyncNotifierConfigInput = z.input; - -/** APNs config block for iOS push. Faithful to Go's mobile `APNsConfig`. */ -export const ApnsConfigSchema = z.object({ - authKeyPath: z.string().min(1), - keyID: z.string().min(1), - teamID: z.string().min(1), - bundleID: z.string().min(1), - production: z.boolean().default(false), -}); -export type ApnsConfigInput = z.input; - -/** FCM config block for Android push. Faithful to Go's mobile `FCMConfig`. */ -export const FcmConfigSchema = z.object({ - /** Path to the Firebase service-account JSON file. Empty ⇒ Application Default Credentials. */ - credentialsPath: z.string().optional(), -}); -export type FcmConfigInput = z.input; - -/** Push-sender providers. `apns_fcm` wires the real senders; `noop` sends nothing. */ -export const PUSH_SENDER_PROVIDERS = ["apns_fcm", "noop"] as const; - -/** - * Selects and configures a {@link import("./mobile.js").PushNotificationSender}. Faithful to Go's - * mobile `Config`: under `apns_fcm`, each platform block is optional and initialized - * independently — a missing or failing platform simply disables that platform. - */ -export const PushSenderConfigSchema = z.object({ - provider: z.enum(PUSH_SENDER_PROVIDERS).default("noop"), - apns: ApnsConfigSchema.optional(), - fcm: FcmConfigSchema.optional(), -}); -export type PushSenderConfig = z.infer; -export type PushSenderConfigInput = z.input; diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts deleted file mode 100644 index bb31406..0000000 --- a/packages/notifications/src/index.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { - makeMetrics, - makeObserver, - type ObservabilityDeps, -} from "@primandproper/observability"; - -import type { AsyncNotifier } from "./async.js"; -import { - AsyncNotifierConfigSchema, - type AsyncNotifierConfigInput, - PushSenderConfigSchema, - type PushSenderConfigInput, -} from "./config.js"; -import { MultiPlatformPushSender, type PushNotificationSender } from "./mobile.js"; -import { AblyAsyncNotifier, newChannelPublisher } from "./providers/ably.node.js"; -import { ApnsSender, newApnsClient } from "./providers/apns.node.js"; -import { NoopAsyncNotifier } from "./providers/async-noop.js"; -import { FcmSender, newFcmClient } from "./providers/fcm.node.js"; -import { NoopPushNotificationSender } from "./providers/mobile-noop.js"; -import { newPusherClient, PusherAsyncNotifier } from "./providers/pusher.node.js"; - -export * from "./async.js"; -export * from "./mobile.js"; -export * from "./config.js"; - -export { NoopAsyncNotifier } from "./providers/async-noop.js"; -export { NoopPushNotificationSender } from "./providers/mobile-noop.js"; -export { - PusherAsyncNotifier, - newPusherClient, - type PusherAsyncNotifierOptions, - type PusherClient, -} from "./providers/pusher.node.js"; -export { - AblyAsyncNotifier, - newChannelPublisher, - type AblyAsyncNotifierOptions, - type ChannelPublisher, -} from "./providers/ably.node.js"; -export { - ApnsSender, - newApnsClient, - type ApnsClient, - type ApnsConfig, - type ApnsNotification, - type ApnsPushResult, -} from "./providers/apns.node.js"; -export { - FcmSender, - newFcmClient, - type FcmClient, - type FcmConfig, - type FcmMessage, -} from "./providers/fcm.node.js"; - -const mobileO11yName = "mobile_push_sender"; - -/** - * Validates config and returns the matching {@link AsyncNotifier}. Faithful to Go's - * `ProvideAsyncNotifier`. `noop` (default) needs no config; `pusher` and `ably` require their - * block (enforced by the schema). `websocket`/`sse` are out of scope — see - * {@link import("./async.js").ConnectionAcceptor}. - */ -export function provideAsyncNotifier( - config?: AsyncNotifierConfigInput, - deps?: ObservabilityDeps, -): AsyncNotifier { - const cfg = AsyncNotifierConfigSchema.parse(config ?? {}); - switch (cfg.provider) { - case "pusher": - return new PusherAsyncNotifier( - newPusherClient(required(cfg.pusher, "pusher")), - deps, - ); - case "ably": - return new AblyAsyncNotifier(newChannelPublisher(required(cfg.ably, "ably")), deps); - case "websocket": - case "sse": - throw new Error( - `async notifier provider '${cfg.provider}' is not implemented in platform-ts: ` + - "server-side connection management + HTTP upgrade is owned by the server framework", - ); - case "noop": - return new NoopAsyncNotifier(); - } -} - -/** - * Validates config and returns the matching {@link PushNotificationSender}. Faithful to Go's - * `ProvidePushSender`: under `apns_fcm` each platform is initialized independently and a failed - * init disables only that platform; when neither platform is available it falls back to noop. - */ -export function providePushSender( - config?: PushSenderConfigInput, - deps?: ObservabilityDeps, -): PushNotificationSender { - const cfg = PushSenderConfigSchema.parse(config ?? {}); - if (cfg.provider === "noop") { - return new NoopPushNotificationSender(); - } - - const logger = (deps?.observer ?? makeObserver(mobileO11yName, deps)).logger(); - // A platform that silently fails to initialize (bad .p8, malformed service account) drops every - // push for that platform; surface it at error with a counter rather than an invisible debug line. - const initFailures = makeMetrics(mobileO11yName, deps?.metrics).counter( - "notifications.push.init_failures", - { - description: - "Push platform initializations that failed and disabled that platform.", - }, - ); - - let apnsSender: ApnsSender | undefined; - if (cfg.apns !== undefined) { - try { - apnsSender = new ApnsSender(newApnsClient(cfg.apns), cfg.apns.bundleID, deps); - } catch (err) { - initFailures.add(1, { platform: "apns" }); - logger.error("APNs sender init failed, iOS push disabled", err, { - platform: "apns", - }); - } - } - - let fcmSender: FcmSender | undefined; - if (cfg.fcm !== undefined) { - try { - fcmSender = new FcmSender(newFcmClient(cfg.fcm), deps); - } catch (err) { - initFailures.add(1, { platform: "fcm" }); - logger.error("FCM sender init failed, Android push disabled", err, { - platform: "fcm", - }); - } - } - - if (apnsSender === undefined && fcmSender === undefined) { - logger.warn("no platform senders available, using noop"); - return new NoopPushNotificationSender(); - } - - return new MultiPlatformPushSender(apnsSender, fcmSender, deps); -} - -/** Narrows a per-provider config the schema's `superRefine` already guaranteed present. */ -function required(value: T | undefined, provider: string): T { - if (value === undefined) { - throw new Error(`${provider} config is required when provider is '${provider}'`); - } - return value; -} diff --git a/packages/notifications/src/mobile.ts b/packages/notifications/src/mobile.ts deleted file mode 100644 index 3b1e337..0000000 --- a/packages/notifications/src/mobile.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import type { ApnsSender } from "./providers/apns.node.js"; -import type { FcmSender } from "./providers/fcm.node.js"; - -/** - * The mobile push contract, ported from platform-go's `notifications/mobile`. A - * {@link PushNotificationSender} routes by platform: APNs for `ios`, FCM for `android`. - */ - -const platformIOS = "ios"; -const platformAndroid = "android"; -const o11yName = "mobile_push_sender"; - -/** - * The content of a push notification. `badgeCount`, when set on iOS, sets the app icon badge. - * Go's `mobile.PushMessage`. - */ -export interface PushMessage { - title: string; - body: string; - badgeCount?: number; -} - -/** - * The generic message payload for mobile push notifications. `requestType` determines which - * handler processes the request; schedulers format the message. Go's `MobileNotificationRequest`. - */ -export interface MobileNotificationRequest { - requestType: string; - title: string; - body: string; - recipientUserIDs: string[]; - context?: Record; - badgeCount?: number; - testID?: string; -} - -/** - * Sends push notifications to device tokens, routing by platform. Faithful to Go's - * `mobile.PushNotificationSender`. - */ -export interface PushNotificationSender { - /** - * Sends a push notification to a single device token. `platform` is `"ios"` or `"android"`; - * implementations filter by platform. - */ - sendPush(platform: string, token: string, msg: PushMessage): Promise; - /** - * Releases the platform senders' resources — the APNs HTTP/2 connection pool and the Firebase - * app — so a graceful shutdown isn't blocked by lingering connections/pollers. Mirrors the - * `close()` {@link import("./async.js").AsyncNotifier} already carries. Idempotent. - */ - close(): Promise; -} - -/** The message carried by every "platform not supported" error; match on it if needed. */ -export const PLATFORM_NOT_SUPPORTED_MESSAGE = - "push notifications not configured for this platform"; - -/** - * Mints a fresh error for sending to a platform that has no configured sender (e.g. an iOS token - * but APNs unconfigured). Go's `ErrPlatformNotSupported`. A factory, not a shared singleton, so - * each throw carries a stack captured at the throw site rather than one frozen at module load. - */ -export function newPlatformNotSupportedError(): Error { - return new Error(PLATFORM_NOT_SUPPORTED_MESSAGE); -} - -/** - * Routes push notifications to APNs (iOS) or FCM (Android). Faithful to Go's - * `MultiPlatformPushSender`: it lowercases the platform, records it on the operation, and routes - * to the matching sender — erroring via {@link newPlatformNotSupportedError} when that sender is - * absent, and on an unknown platform. - */ -export class MultiPlatformPushSender implements PushNotificationSender { - readonly #apnsSender: ApnsSender | undefined; - readonly #fcmSender: FcmSender | undefined; - readonly #observer: Observer; - - constructor( - apnsSender: ApnsSender | undefined, - fcmSender: FcmSender | undefined, - deps?: ObservabilityDeps, - ) { - this.#apnsSender = apnsSender; - this.#fcmSender = fcmSender; - this.#observer = deps?.observer ?? makeObserver(o11yName, deps); - } - - sendPush(platform: string, token: string, msg: PushMessage): Promise { - return this.#observer.run("send_push", async (op) => { - const normalized = platform.trim().toLowerCase(); - op.set("platform", normalized); - - switch (normalized) { - case platformIOS: - if (this.#apnsSender === undefined) { - throw op.error(newPlatformNotSupportedError(), "sending apns notification"); - } - return this.#apnsSender.send(token, msg.title, msg.body, msg.badgeCount); - case platformAndroid: - if (this.#fcmSender === undefined) { - throw op.error(newPlatformNotSupportedError(), "sending fcm notification"); - } - return this.#fcmSender.send(token, msg.title, msg.body); - default: - throw op.error( - new Error(`unknown platform ${JSON.stringify(normalized)}`), - "routing push notification", - ); - } - }); - } - - /** Closes whichever platform senders are configured, so neither leaks its client on shutdown. */ - async close(): Promise { - await Promise.allSettled([this.#apnsSender?.close(), this.#fcmSender?.close()]); - } -} diff --git a/packages/notifications/src/notifications.test.ts b/packages/notifications/src/notifications.test.ts deleted file mode 100644 index 99b25ff..0000000 --- a/packages/notifications/src/notifications.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { - makeRecordingObserver, - type MeterProvider, - type ObservabilityDeps, -} from "@primandproper/observability"; -import { describe, expect, it, vi } from "vitest"; - -import { - AblyAsyncNotifier, - ApnsSender, - type ApnsClient, - type AsyncNotifier, - type ChannelPublisher, - PLATFORM_NOT_SUPPORTED_MESSAGE, - FcmSender, - type FcmClient, - MultiPlatformPushSender, - NoopAsyncNotifier, - NoopPushNotificationSender, - provideAsyncNotifier, - providePushSender, - type PushNotificationSender, - PusherAsyncNotifier, - type PusherClient, -} from "./index.js"; - -/** A MeterProvider that records every counter `add`, so tests can assert send/error counters. */ -function countingMeter(): { deps: ObservabilityDeps; counts: Map } { - const counts = new Map(); - const meter = { - createCounter: (name: string) => ({ - add: (value: number) => counts.set(name, (counts.get(name) ?? 0) + value), - }), - // makeObserver auto-mints an operation-duration histogram (OBS-1); the fake meter must - // provide it even though these tests only assert on the counters. - createHistogram: () => ({ record: () => undefined }), - createUpDownCounter: () => ({ add: () => undefined }), - createGauge: () => ({ record: () => undefined }), - }; - const provider = { getMeter: () => meter } as unknown as MeterProvider; - return { deps: { metrics: provider }, counts }; -} - -describe("PusherAsyncNotifier", () => { - it("triggers the channel with the event type and data, counting the send", async () => { - const trigger = vi.fn().mockResolvedValue(undefined); - const { deps, counts } = countingMeter(); - - const notifier = new PusherAsyncNotifier({ trigger }, deps); - await notifier.publish("room-1", { type: "message", data: { hello: "world" } }); - - expect(trigger).toHaveBeenCalledWith("room-1", "message", { hello: "world" }); - expect(counts.get("async_notifications_pusher_sends")).toBe(1); - notifier.close(); - }); - - it("surfaces a trigger failure through the operation and counts the error", async () => { - const boom = new Error("pusher down"); - const observer = makeRecordingObserver(); - const { deps, counts } = countingMeter(); - - const notifier = new PusherAsyncNotifier( - { trigger: () => Promise.reject(boom) }, - { ...deps, observer }, - ); - - await expect(notifier.publish("room-1", { type: "message" })).rejects.toBe(boom); - expect(counts.get("async_notifications_pusher_errors")).toBe(1); - expect(observer.errors.map((e) => e.description)).toContain( - "publishing to pusher channel", - ); - }); -}); - -describe("AblyAsyncNotifier", () => { - it("publishes the event name and data to the channel", async () => { - const publish = vi.fn().mockResolvedValue(undefined); - const { deps, counts } = countingMeter(); - - const notifier = new AblyAsyncNotifier({ publish }, deps); - await notifier.publish("chan", { type: "tick", data: 42 }); - - expect(publish).toHaveBeenCalledWith("chan", "tick", 42); - expect(counts.get("async_notifications_ably_sends")).toBe(1); - }); - - it("surfaces a publish failure and counts the error", async () => { - const boom = new Error("ably down"); - const { deps, counts } = countingMeter(); - const notifier = new AblyAsyncNotifier({ publish: () => Promise.reject(boom) }, deps); - - await expect(notifier.publish("chan", { type: "tick" })).rejects.toBe(boom); - expect(counts.get("async_notifications_ably_errors")).toBe(1); - }); -}); - -describe("ApnsSender", () => { - const validToken = "a".repeat(64); - - it("sends an alert to a valid device token, counting the send", async () => { - const send = vi - .fn() - .mockResolvedValue({ sent: [{ device: validToken }], failed: [] }); - const { deps, counts } = countingMeter(); - - const sender = new ApnsSender({ send }, "com.example.app", deps); - await sender.send(validToken, "Title", "Body", 3); - - expect(send).toHaveBeenCalledWith( - { - alert: { title: "Title", body: "Body" }, - badge: 3, - topic: "com.example.app", - priority: 10, - }, - validToken, - ); - expect(counts.get("ios_notif_sender_sends")).toBe(1); - }); - - it("rejects a malformed device token before calling the client", async () => { - const send = vi.fn(); - const sender = new ApnsSender({ send }, "com.example.app"); - - await expect(sender.send("not-hex", "T", "B")).rejects.toThrow( - /invalid device token/, - ); - expect(send).not.toHaveBeenCalled(); - }); - - it("errors and counts when APNs reports the token failed", async () => { - const send = vi.fn().mockResolvedValue({ - sent: [], - failed: [{ device: validToken, status: 410, response: { reason: "Unregistered" } }], - }); - const { deps, counts } = countingMeter(); - const sender = new ApnsSender({ send }, "com.example.app", deps); - - await expect(sender.send(validToken, "T", "B")).rejects.toThrow(/Unregistered/); - expect(counts.get("ios_notif_sender_errors")).toBe(1); - }); -}); - -describe("FcmSender", () => { - it("sends a notification and records the message id", async () => { - const send = vi.fn().mockResolvedValue("projects/x/messages/1"); - const { deps, counts } = countingMeter(); - - const sender = new FcmSender({ send }, deps); - await sender.send("device-token", "Title", "Body"); - - expect(send).toHaveBeenCalledWith({ - token: "device-token", - notification: { title: "Title", body: "Body" }, - }); - expect(counts.get("android_notif_sender_sends")).toBe(1); - }); - - it("surfaces a send failure and counts the error", async () => { - const boom = new Error("fcm down"); - const { deps, counts } = countingMeter(); - const sender = new FcmSender({ send: () => Promise.reject(boom) }, deps); - - await expect(sender.send("device-token", "T", "B")).rejects.toBe(boom); - expect(counts.get("android_notif_sender_errors")).toBe(1); - }); -}); - -describe("MultiPlatformPushSender", () => { - const iosToken = "b".repeat(64); - - function build(opts: { apns?: boolean; fcm?: boolean }): { - sender: MultiPlatformPushSender; - apnsSend: ReturnType>; - fcmSend: ReturnType>; - } { - const apnsSend = vi - .fn() - .mockResolvedValue({ sent: [{ device: iosToken }], failed: [] }); - const fcmSend = vi.fn().mockResolvedValue("msg-1"); - const apnsSender = opts.apns - ? new ApnsSender({ send: apnsSend }, "com.example.app") - : undefined; - const fcmSender = opts.fcm ? new FcmSender({ send: fcmSend }) : undefined; - return { - sender: new MultiPlatformPushSender(apnsSender, fcmSender), - apnsSend, - fcmSend, - }; - } - - it("routes iOS (case-insensitive) to APNs and Android to FCM", async () => { - const { sender, apnsSend, fcmSend } = build({ apns: true, fcm: true }); - - await sender.sendPush("iOS", iosToken, { title: "T", body: "B" }); - await sender.sendPush("android", "android-token", { title: "T", body: "B" }); - - expect(apnsSend).toHaveBeenCalledTimes(1); - expect(fcmSend).toHaveBeenCalledTimes(1); - }); - - it("rejects when the platform sender is absent", async () => { - const { sender } = build({ fcm: true }); - await expect( - sender.sendPush("ios", iosToken, { title: "T", body: "B" }), - ).rejects.toThrow(PLATFORM_NOT_SUPPORTED_MESSAGE); - }); - - it("rejects an unknown platform", async () => { - const { sender } = build({ apns: true, fcm: true }); - await expect( - sender.sendPush("blackberry", "token", { title: "T", body: "B" }), - ).rejects.toThrow(/unknown platform/); - }); - - // LC-7: close() must release both platform clients rather than leaking them on shutdown. - it("closes both platform senders on close", async () => { - const apnsClose = vi.fn<() => Promise>().mockResolvedValue(undefined); - const fcmClose = vi.fn<() => Promise>().mockResolvedValue(undefined); - const apnsSender = new ApnsSender( - { send: vi.fn(), close: apnsClose }, - "com.example.app", - ); - const fcmSender = new FcmSender({ send: vi.fn(), close: fcmClose }); - - await new MultiPlatformPushSender(apnsSender, fcmSender).close(); - - expect(apnsClose).toHaveBeenCalledOnce(); - expect(fcmClose).toHaveBeenCalledOnce(); - }); - - it("close is a no-op when a platform sender is absent", async () => { - const { sender } = build({ apns: true }); - await expect(sender.close()).resolves.toBeUndefined(); - }); -}); - -describe("noop providers", () => { - it("NoopAsyncNotifier publishes and closes without throwing", async () => { - const notifier: AsyncNotifier = new NoopAsyncNotifier(); - await expect(notifier.publish("c", { type: "t" })).resolves.toBeUndefined(); - expect(() => { - notifier.close(); - }).not.toThrow(); - }); - - it("NoopPushNotificationSender sends and closes without throwing", async () => { - const sender: PushNotificationSender = new NoopPushNotificationSender(); - await expect( - sender.sendPush("ios", "token", { title: "T", body: "B" }), - ).resolves.toBeUndefined(); - await expect(sender.close()).resolves.toBeUndefined(); - }); -}); - -describe("provideAsyncNotifier", () => { - it("defaults to a noop notifier", () => { - expect(provideAsyncNotifier()).toBeInstanceOf(NoopAsyncNotifier); - }); - - it("requires the pusher block when provider is pusher", () => { - expect(() => provideAsyncNotifier({ provider: "pusher" })).toThrow( - /pusher config is required/, - ); - }); - - it("builds a pusher notifier from a valid config", () => { - const notifier = provideAsyncNotifier({ - provider: "pusher", - pusher: { appID: "1", key: "k", secret: "s", cluster: "us1" }, - }); - expect(notifier).toBeInstanceOf(PusherAsyncNotifier); - }); - - it("builds an ably notifier from a valid config", () => { - const notifier = provideAsyncNotifier({ provider: "ably", ably: { apiKey: "x:y" } }); - expect(notifier).toBeInstanceOf(AblyAsyncNotifier); - }); - - it("rejects the out-of-scope websocket/sse providers", () => { - expect(() => provideAsyncNotifier({ provider: "websocket" })).toThrow( - /not implemented/, - ); - expect(() => provideAsyncNotifier({ provider: "sse" })).toThrow(/not implemented/); - }); -}); - -describe("providePushSender", () => { - it("defaults to a noop sender", () => { - expect(providePushSender()).toBeInstanceOf(NoopPushNotificationSender); - }); - - it("falls back to noop under apns_fcm when neither platform is configured", () => { - expect(providePushSender({ provider: "apns_fcm" })).toBeInstanceOf( - NoopPushNotificationSender, - ); - }); - - it("counts a platform init failure instead of failing silently", () => { - // A missing service-account file makes firebase-admin's cert() throw at construction, which - // is exactly the "iOS/Android push silently disabled" case INST-3 makes visible. - const { deps, counts } = countingMeter(); - - const sender = providePushSender( - { provider: "apns_fcm", fcm: { credentialsPath: "/does/not/exist.json" } }, - deps, - ); - - expect(counts.get("notifications.push.init_failures")).toBe(1); - expect(sender).toBeInstanceOf(NoopPushNotificationSender); - }); -}); diff --git a/packages/notifications/src/providers/ably.node.ts b/packages/notifications/src/providers/ably.node.ts deleted file mode 100644 index 05b52bb..0000000 --- a/packages/notifications/src/providers/ably.node.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import { Rest } from "ably"; - -import type { AsyncEvent, AsyncNotifier } from "../async.js"; -import { senderInstruments, type SenderInstruments } from "../support.js"; - -const o11yName = "async_notifications_ably"; - -/** Ably async notifier config. Faithful to Go's `ably.Config`. */ -export interface AblyAsyncNotifierOptions { - apiKey: string; -} - -/** - * Abstracts Ably channel publishing for testability. Faithful to Go's `ChannelPublisher` - * interface — inject a fake to unit-test without hitting the network. - */ -export interface ChannelPublisher { - publish(channel: string, name: string, data: unknown): Promise; -} - -/** Builds a {@link ChannelPublisher} backed by the real Ably REST client. */ -export function newChannelPublisher(options: AblyAsyncNotifierOptions): ChannelPublisher { - const client = new Rest({ key: options.apiKey }); - return { - publish: async (channel, name, data) => { - await client.channels.get(channel).publish(name, data); - }, - }; -} - -/** An Ably-backed {@link AsyncNotifier}. Faithful to Go's `ably.Notifier`. */ -export class AblyAsyncNotifier implements AsyncNotifier { - readonly #publisher: ChannelPublisher; - readonly #observer: Observer; - readonly #instruments: SenderInstruments; - - constructor(publisher: ChannelPublisher, deps?: ObservabilityDeps) { - this.#publisher = publisher; - this.#observer = deps?.observer ?? makeObserver(o11yName, deps); - this.#instruments = senderInstruments(o11yName, deps); - } - - publish(channel: string, event: AsyncEvent): Promise { - return this.#observer.run("publish", async (op) => { - op.set("channel", channel).set("event.type", event.type); - try { - await this.#publisher.publish(channel, event.type, event.data); - } catch (err) { - this.#instruments.errors.add(1); - throw op.error(err, "publishing to ably channel"); - } - this.#instruments.sends.add(1); - }); - } - - /** No-op: the Ably REST client holds no persistent connection. */ - close(): void {} -} diff --git a/packages/notifications/src/providers/apns.node.ts b/packages/notifications/src/providers/apns.node.ts deleted file mode 100644 index b29e79d..0000000 --- a/packages/notifications/src/providers/apns.node.ts +++ /dev/null @@ -1,160 +0,0 @@ -import apn from "@parse/node-apn"; -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { senderInstruments, type SenderInstruments } from "../support.js"; - -const o11yName = "ios_notif_sender"; - -/** Validates a 64-character hex string (32-byte token). Faithful to Go's regex. */ -const apnsDeviceTokenHexPattern = /^[0-9a-fA-F]{64}$/; - -/** APNs configuration. Faithful to Go's `apns.Config`. */ -export interface ApnsConfig { - /** Path to the `.p8` auth key file. */ - authKeyPath: string; - keyID: string; - teamID: string; - /** The app's bundle identifier, used as the APNs topic. */ - bundleID: string; - /** Production vs development APNs gateway. */ - production?: boolean; -} - -/** The notification shape the {@link ApnsClient} seam accepts. */ -export interface ApnsNotification { - alert: { title: string; body: string }; - badge?: number; - topic: string; - priority: number; -} - -/** - * One device's outcome from a send. `@parse/node-apn` batches results into `sent`/`failed`; - * a failed entry carries the APNs `status` and `response.reason`. - */ -export interface ApnsPushResult { - sent: { device: string }[]; - failed: { - device: string; - status?: string | number; - response?: { reason?: string } | null; - error?: unknown; - }[]; -} - -/** - * The slice of the APNs SDK the sender depends on, extracted for testability — inject a fake to - * unit-test without a real key or network. Go's seam is the `apns2.Client`; the TS SDK batches, - * so the seam returns its `{sent, failed}` result. - */ -export interface ApnsClient { - send(notification: ApnsNotification, deviceToken: string): Promise; - /** Releases the underlying APNs connection pool. Optional so injected fakes need not implement it. */ - close?(): Promise; -} - -/** Builds an {@link ApnsClient} backed by the real `@parse/node-apn` provider. */ -export function newApnsClient(config: ApnsConfig): ApnsClient { - const provider = new apn.Provider({ - token: { - key: config.authKeyPath, - keyId: config.keyID, - teamId: config.teamID, - }, - production: config.production ?? false, - }); - - return { - async send(notification, deviceToken) { - const note = new apn.Notification(); - note.topic = notification.topic; - note.priority = notification.priority; - note.alert = notification.alert; - if (notification.badge !== undefined) { - note.badge = notification.badge; - } - return provider.send(note, deviceToken); - }, - close() { - // Drains and closes the APNs HTTP/2 connection pool so it stops pinning the event loop. - return provider.shutdown(); - }, - }; -} - -/** - * Sends push notifications to iOS devices via APNs. Faithful to Go's `apns.Sender`: it guards the - * device-token format, sends at high priority, and records `status`/`reason` on failure. The - * notification title is user content, so it is deliberately kept out of telemetry (INST-7). - * `badgeCount`, when set, maps to `aps.badge`. - */ -export class ApnsSender { - readonly #client: ApnsClient; - readonly #topic: string; - readonly #observer: Observer; - readonly #instruments: SenderInstruments; - - constructor(client: ApnsClient, topic: string, deps?: ObservabilityDeps) { - this.#client = client; - this.#topic = topic; - this.#observer = deps?.observer ?? makeObserver(o11yName, deps); - this.#instruments = senderInstruments(o11yName, deps); - } - - send( - deviceToken: string, - title: string, - body: string, - badgeCount?: number, - ): Promise { - return this.#observer.run("send", async (op) => { - if (!apnsDeviceTokenHexPattern.test(deviceToken)) { - throw op.error( - new Error( - `apns: invalid device token format (expected 64 hex chars, got len ${String(deviceToken.length)})`, - ), - "validating device token", - ); - } - - const notification: ApnsNotification = { - alert: { title, body }, - topic: this.#topic, - priority: 10, - }; - if (badgeCount !== undefined) { - notification.badge = badgeCount; - } - - let result: ApnsPushResult; - try { - result = await this.#client.send(notification, deviceToken); - } catch (err) { - this.#instruments.errors.add(1); - throw op.error(err, "apns: push failed"); - } - - const failure = result.failed[0]; - if (failure !== undefined) { - this.#instruments.errors.add(1); - const reason = failure.response?.reason ?? "unknown"; - op.set("statusCode", failure.status ?? "").set("reason", reason); - throw op.error( - new Error(`apns: ${reason} (status ${String(failure.status ?? "")})`), - "sending apns notification", - ); - } - - this.#instruments.sends.add(1); - }); - } - - /** Releases the APNs connection pool. Idempotent; a no-op for a client without `close`. */ - async close(): Promise { - await this.#client.close?.(); - } -} diff --git a/packages/notifications/src/providers/async-noop.ts b/packages/notifications/src/providers/async-noop.ts deleted file mode 100644 index 9fbdf6c..0000000 --- a/packages/notifications/src/providers/async-noop.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { AsyncNotifier } from "../async.js"; - -/** An {@link AsyncNotifier} that discards every event. Go's `async/noop`. */ -export class NoopAsyncNotifier implements AsyncNotifier { - publish(): Promise { - return Promise.resolve(); - } - - close(): void {} -} diff --git a/packages/notifications/src/providers/fcm.node.ts b/packages/notifications/src/providers/fcm.node.ts deleted file mode 100644 index d9848d3..0000000 --- a/packages/notifications/src/providers/fcm.node.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import { applicationDefault, cert, deleteApp, initializeApp } from "firebase-admin/app"; -import { getMessaging } from "firebase-admin/messaging"; - -import { senderInstruments, type SenderInstruments } from "../support.js"; - -const o11yName = "android_notif_sender"; - -/** Distinguishes each initialized Firebase app so repeated factory calls don't collide. */ -let appCounter = 0; - -/** FCM configuration. Faithful to Go's `fcm.Config`. */ -export interface FcmConfig { - /** - * Path to the Firebase service-account JSON file. When empty, Application Default Credentials - * (ADC) are used. - */ - credentialsPath?: string | undefined; -} - -/** The message shape the {@link FcmClient} seam accepts. */ -export interface FcmMessage { - token: string; - notification: { title: string; body: string }; -} - -/** - * The slice of the FCM SDK the sender depends on, extracted for testability — inject a fake to - * unit-test without real credentials. `send` resolves to the FCM message id. - */ -export interface FcmClient { - send(message: FcmMessage): Promise; - /** Deletes the underlying Firebase app. Optional so injected fakes need not implement it. */ - close?(): Promise; -} - -/** Builds an {@link FcmClient} backed by the real `firebase-admin` messaging client. */ -export function newFcmClient(config: FcmConfig): FcmClient { - const credential = - config.credentialsPath !== undefined && config.credentialsPath !== "" - ? cert(config.credentialsPath) - : applicationDefault(); - - appCounter += 1; - const app = initializeApp({ credential }, `notifications-fcm-${String(appCounter)}`); - const messaging = getMessaging(app); - - return { - send: (message) => messaging.send(message), - // Deletes the initialized app so its background work stops keeping the process alive. - close: () => deleteApp(app), - }; -} - -/** - * Sends push notifications to Android devices via FCM. Faithful to Go's `fcm.Sender`: it records - * the returned `fcm.message_id`. The notification title is user content, so it is deliberately - * kept out of telemetry (INST-7). - */ -export class FcmSender { - readonly #client: FcmClient; - readonly #observer: Observer; - readonly #instruments: SenderInstruments; - - constructor(client: FcmClient, deps?: ObservabilityDeps) { - this.#client = client; - this.#observer = deps?.observer ?? makeObserver(o11yName, deps); - this.#instruments = senderInstruments(o11yName, deps); - } - - send(deviceToken: string, title: string, body: string): Promise { - return this.#observer.run("send", async (op) => { - let messageID: string; - try { - messageID = await this.#client.send({ - token: deviceToken, - notification: { title, body }, - }); - } catch (err) { - this.#instruments.errors.add(1); - throw op.error(err, "sending fcm message"); - } - - op.set("fcm.message_id", messageID); - this.#instruments.sends.add(1); - }); - } - - /** Deletes the backing Firebase app. Idempotent; a no-op for a client without `close`. */ - async close(): Promise { - await this.#client.close?.(); - } -} diff --git a/packages/notifications/src/providers/mobile-noop.ts b/packages/notifications/src/providers/mobile-noop.ts deleted file mode 100644 index 0dc44e7..0000000 --- a/packages/notifications/src/providers/mobile-noop.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { PushNotificationSender } from "../mobile.js"; - -/** - * A {@link PushNotificationSender} that sends nothing. Used when neither APNs nor FCM is - * configured. Go's `mobile/noop`. - */ -export class NoopPushNotificationSender implements PushNotificationSender { - sendPush(): Promise { - return Promise.resolve(); - } - - close(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/notifications/src/providers/pusher.node.ts b/packages/notifications/src/providers/pusher.node.ts deleted file mode 100644 index a359bfb..0000000 --- a/packages/notifications/src/providers/pusher.node.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import Pusher from "pusher"; - -import type { AsyncEvent, AsyncNotifier } from "../async.js"; -import { senderInstruments, type SenderInstruments } from "../support.js"; - -const o11yName = "async_notifications_pusher"; - -/** Pusher async notifier config. Faithful to Go's `pusher.Config`. */ -export interface PusherAsyncNotifierOptions { - appID: string; - key: string; - secret: string; - cluster: string; - /** Whether to use TLS for the Pusher API connection. Go's `Secure`. */ - secure?: boolean; -} - -/** - * The slice of the Pusher SDK the notifier depends on, extracted for testability. Faithful to - * Go's `PusherClient` interface — inject a fake to unit-test without hitting the network. - */ -export interface PusherClient { - trigger(channel: string, eventName: string, data: unknown): Promise; -} - -/** Builds a {@link PusherClient} backed by the real `pusher` SDK. */ -export function newPusherClient(options: PusherAsyncNotifierOptions): PusherClient { - const client = new Pusher({ - appId: options.appID, - key: options.key, - secret: options.secret, - cluster: options.cluster, - useTLS: options.secure ?? true, - }); - return { - trigger: (channel, eventName, data) => client.trigger(channel, eventName, data), - }; -} - -/** A Pusher-backed {@link AsyncNotifier}. Faithful to Go's `pusher.Notifier`. */ -export class PusherAsyncNotifier implements AsyncNotifier { - readonly #client: PusherClient; - readonly #observer: Observer; - readonly #instruments: SenderInstruments; - - constructor(client: PusherClient, deps?: ObservabilityDeps) { - this.#client = client; - this.#observer = deps?.observer ?? makeObserver(o11yName, deps); - this.#instruments = senderInstruments(o11yName, deps); - } - - publish(channel: string, event: AsyncEvent): Promise { - return this.#observer.run("publish", async (op) => { - op.set("pusher.channel", channel).set("pusher.event_type", event.type); - try { - await this.#client.trigger(channel, event.type, event.data); - } catch (err) { - this.#instruments.errors.add(1); - throw op.error(err, "publishing to pusher channel"); - } - this.#instruments.sends.add(1); - }); - } - - /** No-op: the Pusher notifier is a stateless HTTP client. */ - close(): void {} -} diff --git a/packages/notifications/src/support.ts b/packages/notifications/src/support.ts deleted file mode 100644 index faa7db6..0000000 --- a/packages/notifications/src/support.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - makeMetrics, - type Metrics, - type ObservabilityDeps, -} from "@primandproper/observability"; - -type Counter = ReturnType; - -/** - * The `_sends` / `_errors` counter pair every Go notifier and sender mints in its constructor - * (`metrics.EnsureMetricsProvider(...).NewInt64Counter(o11yName + "_sends"/"_errors")`). The - * meter is registered under the component's o11y name so instruments group with its spans/logs. - */ -export interface SenderInstruments { - sends: Counter; - errors: Counter; -} - -/** Builds the `{o11yName}_sends` / `{o11yName}_errors` counters, defaulting to the noop meter. */ -export function senderInstruments( - o11yName: string, - deps: ObservabilityDeps | undefined, -): SenderInstruments { - const metrics = makeMetrics(o11yName, deps?.metrics); - return { - sends: metrics.counter(`${o11yName}_sends`), - errors: metrics.counter(`${o11yName}_errors`), - }; -} diff --git a/packages/notifications/tsconfig.json b/packages/notifications/tsconfig.json deleted file mode 100644 index bdb9327..0000000 --- a/packages/notifications/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022"], - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/notifications/tsup.config.ts b/packages/notifications/tsup.config.ts deleted file mode 100644 index 8bf2394..0000000 --- a/packages/notifications/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { index: "src/index.ts" }, - platform: "node", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: true, -}); diff --git a/packages/search/CHANGELOG.md b/packages/search/CHANGELOG.md deleted file mode 100644 index 772deec..0000000 --- a/packages/search/CHANGELOG.md +++ /dev/null @@ -1,25 +0,0 @@ -# @primandproper/search - -## 0.1.1 - -### Patch Changes - -- a124406: Add bulk indexing interfaces (`BulkIndexManager`, `BulkTextIndex`, `BulkDocument`), type guards, and a default search limit. -- Updated dependencies [a124406] -- Updated dependencies [a124406] -- Updated dependencies [a124406] - - @primandproper/circuitbreaking@0.0.2 - - @primandproper/errors@0.0.2 - - @primandproper/observability@0.1.0 - -## 0.1.0 - -### Minor Changes - -- 19b79dc: add the generic `DocumentIndex` surface — a faithful port of `platform-go/search/text`. - Provides `index(id, value)` / `search(query): T[]` / `delete(id)` / `wipe()` over whole - documents, with `noop`, `algolia`, and `elasticsearch` providers, an embedded circuit breaker, - injected observability, and an async `provideDocumentIndex(indexName, config?, deps?)` - factory (defaults to `noop`). Also ports the `IndexRequest` type and the `QUERY_KEY_SEARCH` - constant. The existing `TextIndex`/`VectorIndex` surface is unchanged. The async indexing - scheduler is deferred to a follow-up. diff --git a/packages/search/package.json b/packages/search/package.json deleted file mode 100644 index a865045..0000000 --- a/packages/search/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@primandproper/search", - "version": "0.1.1", - "type": "module", - "description": "Server-only search: text and vector index/search interfaces with swappable providers.", - "license": "AGPL-3.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/primandproper/platform-ts.git", - "directory": "packages/search" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "eslint .", - "clean": "rm -rf dist .turbo" - }, - "dependencies": { - "@elastic/elasticsearch": "8.19.1", - "@primandproper/circuitbreaking": "workspace:*", - "@primandproper/errors": "workspace:*", - "@primandproper/observability": "workspace:*", - "algoliasearch": "5.55.0", - "typesense": "3.0.6", - "zod": "3.25.76" - } -} diff --git a/packages/search/src/config.ts b/packages/search/src/config.ts deleted file mode 100644 index 2432695..0000000 --- a/packages/search/src/config.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { CircuitBreakerConfigSchema } from "@primandproper/circuitbreaking"; -import { z } from "zod"; - -/** Typesense-provider config: connects to a Typesense server over HTTP with an API key. */ -export const TypesenseTextConfigSchema = z.object({ - apiKey: z.string(), - host: z.string().default("localhost"), - port: z.number().int().positive().default(8108), - protocol: z.enum(["http", "https"]).default("http"), - /** Collection that backs the index; created on first use if absent. */ - collection: z.string().default("text"), - /** Per-request connection timeout in seconds. */ - connectionTimeoutSeconds: z.number().int().positive().default(10), -}); - -export type TypesenseTextConfig = z.infer; - -/** - * Text search config. Replaces the Go `env:`-tagged struct + ozzo `ValidateWithContext`. - * `memory` (default) keeps an in-process inverted index; `noop` indexes nothing; - * `typesense` delegates to a running Typesense server. Elasticsearch/OpenSearch is - * documented as a future provider — it needs a provider SDK and a running server and stays - * server-side. - */ -export const TextSearchConfigSchema = z - .object({ - provider: z.enum(["memory", "noop", "typesense"]).default("memory"), - typesense: TypesenseTextConfigSchema.optional(), - }) - .superRefine((cfg, ctx) => { - if (cfg.provider === "typesense" && cfg.typesense === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["typesense"], - message: "typesense config is required when provider is 'typesense'", - }); - } - }); - -export type TextSearchConfig = z.infer; -export type TextSearchConfigInput = z.input; - -/** Algolia-provider config for the generic {@link import("./document-index.js").DocumentIndex}. */ -export const AlgoliaConfigSchema = z.object({ - /** Algolia application id. */ - appID: z.string(), - /** Algolia write-capable API key. */ - apiKey: z.string(), -}); - -export type AlgoliaConfig = z.infer; - -/** Elasticsearch-provider config for the generic {@link import("./document-index.js").DocumentIndex}. */ -export const ElasticsearchConfigSchema = z.object({ - /** Node address, e.g. `http://localhost:9200`. */ - address: z.string(), - username: z.string().default(""), - password: z.string().default(""), - /** PEM CA certificate for TLS verification. */ - caCert: z.string().optional(), - /** Per-index-operation timeout in milliseconds. */ - indexOperationTimeoutMs: z.number().int().positive().optional(), - /** Readiness-poll attempts before the factory gives up. */ - readinessAttempts: z.number().int().positive().default(10), -}); - -export type ElasticsearchConfig = z.infer; - -/** - * Config for the generic {@link import("./document-index.js").DocumentIndex} surface — the - * faithful port of platform-go's `textsearch` config. `noop` (default, matching Go's fallback) - * indexes nothing; `algolia` and `elasticsearch` delegate to their server-side backends. The - * embedded circuit-breaker config wraps every provider call. - */ -export const DocumentIndexConfigSchema = z - .object({ - provider: z.enum(["algolia", "elasticsearch", "noop"]).default("noop"), - algolia: AlgoliaConfigSchema.optional(), - elasticsearch: ElasticsearchConfigSchema.optional(), - circuitBreaker: CircuitBreakerConfigSchema.optional(), - }) - .superRefine((cfg, ctx) => { - if (cfg.provider === "algolia" && cfg.algolia === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["algolia"], - message: "algolia config is required when provider is 'algolia'", - }); - } - if (cfg.provider === "elasticsearch" && cfg.elasticsearch === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["elasticsearch"], - message: "elasticsearch config is required when provider is 'elasticsearch'", - }); - } - }); - -export type DocumentIndexConfig = z.infer; -export type DocumentIndexConfigInput = z.input; diff --git a/packages/search/src/document-index.test.ts b/packages/search/src/document-index.test.ts deleted file mode 100644 index e1e41c0..0000000 --- a/packages/search/src/document-index.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import type { CircuitBreaker } from "@primandproper/circuitbreaking"; -import { isPlatformError } from "@primandproper/errors"; -import { makeRecordingObserver } from "@primandproper/observability"; -import { describe, expect, it, vi } from "vitest"; - -import { - CircuitBrokenError, - EmptyQueryError, - isBulkIndexManager, - SEARCH_QUERY_KEY, - type DocumentIndex, -} from "./document-index.js"; -import { AlgoliaDocumentIndex } from "./providers/algolia.node.js"; -import { ElasticsearchDocumentIndex } from "./providers/elasticsearch.node.js"; -import { NoopDocumentIndex } from "./providers/noop-document-index.js"; - -import { provideDocumentIndex } from "./index.js"; - -interface Doc { - id: string; - name: string; -} - -/** A fake circuit breaker that records its calls and answers `canProceed` from a fixed value. */ -function fakeBreaker(canProceed: boolean): CircuitBreaker & { - canProceed: ReturnType; - succeeded: ReturnType; - failed: ReturnType; -} { - return { - canProceed: vi.fn(() => canProceed), - succeeded: vi.fn(), - failed: vi.fn(), - }; -} - -/** - * Provider-agnostic conformance: every backend must accept writes/deletes/wipes without - * throwing and return a (possibly empty) array from search. Real engines rank by their own - * scoring, so this asserts only the shared contract, run against the always-available noop. - */ -function conformance(name: string, make: () => DocumentIndex): void { - describe(name, () => { - it("returns an array from search", async () => { - const results = await make().search("anything"); - expect(Array.isArray(results)).toBe(true); - }); - - it("indexes, deletes, and wipes without throwing", async () => { - const index = make(); - await expect(index.index("a", { id: "a", name: "first" })).resolves.toBeUndefined(); - await expect(index.delete("a")).resolves.toBeUndefined(); - await expect(index.wipe()).resolves.toBeUndefined(); - }); - }); -} - -conformance("NoopDocumentIndex", () => new NoopDocumentIndex()); - -describe("bulk indexing seam (PERF-5)", () => { - it("exposes a discoverable indexMany on the noop manager", async () => { - const index: DocumentIndex = new NoopDocumentIndex(); - expect(isBulkIndexManager(index)).toBe(true); - // The guard narrows to the bulk surface — the intended way callers reach indexMany. - if (isBulkIndexManager(index)) { - await expect( - index.indexMany([ - { id: "1", value: { id: "1", name: "a" } }, - { id: "2", value: { id: "2", name: "b" } }, - ]), - ).resolves.toBeUndefined(); - } - }); -}); - -describe("provideDocumentIndex", () => { - it("defaults to the noop provider", async () => { - const index = await provideDocumentIndex("test-index"); - expect(index).toBeInstanceOf(NoopDocumentIndex); - }); - - it("builds an algolia provider when configured", async () => { - const index = await provideDocumentIndex("test-index", { - provider: "algolia", - algolia: { appID: "app", apiKey: "key" }, - }); - expect(index).toBeInstanceOf(AlgoliaDocumentIndex); - }); - - it("rejects when algolia is selected without its config", async () => { - await expect( - provideDocumentIndex("test-index", { provider: "algolia" }), - ).rejects.toThrow(/algolia/); - }); - - it("rejects when elasticsearch is selected without its config", async () => { - await expect( - provideDocumentIndex("test-index", { provider: "elasticsearch" }), - ).rejects.toThrow(/elasticsearch/); - }); -}); - -describe("circuit breaker guarding", () => { - it("rejects every method with CircuitBrokenError when the breaker is open", async () => { - const cb = fakeBreaker(false); - const index = new AlgoliaDocumentIndex( - { appID: "app", apiKey: "key", indexName: "test" }, - cb, - ); - - await expect(index.index("a", { id: "a", name: "x" })).rejects.toBeInstanceOf( - CircuitBrokenError, - ); - await expect(index.search("q")).rejects.toBeInstanceOf(CircuitBrokenError); - await expect(index.delete("a")).rejects.toBeInstanceOf(CircuitBrokenError); - await expect(index.wipe()).rejects.toBeInstanceOf(CircuitBrokenError); - - // Open breaker: the SDK is never reached, so neither outcome is reported. - expect(cb.succeeded).not.toHaveBeenCalled(); - expect(cb.failed).not.toHaveBeenCalled(); - }); - - it("also guards algolia index (the fix over the Go source)", async () => { - const cb = fakeBreaker(false); - const index = new AlgoliaDocumentIndex( - { appID: "app", apiKey: "key", indexName: "test" }, - cb, - ); - - await expect(index.index("a", { id: "a", name: "x" })).rejects.toBeInstanceOf( - CircuitBrokenError, - ); - expect(cb.canProceed).toHaveBeenCalled(); - }); -}); - -describe("query and observability", () => { - it("rejects an empty query and still observes the query key", async () => { - const observer = makeRecordingObserver(); - const index = new AlgoliaDocumentIndex( - { appID: "app", apiKey: "key", indexName: "test" }, - fakeBreaker(true), - { observer }, - ); - - await expect(index.search("")).rejects.toBeInstanceOf(EmptyQueryError); - expect(observer.observed(SEARCH_QUERY_KEY)).toBe(true); - }); - - it("elasticsearch wipe is unimplemented", async () => { - const index = new ElasticsearchDocumentIndex( - { address: "http://localhost:9200", indexName: "test", readinessAttempts: 1 }, - fakeBreaker(true), - ); - - const err: unknown = await index.wipe().catch((reason: unknown) => reason); - expect(isPlatformError(err, "search/unimplemented")).toBe(true); - }); -}); - -/** - * Live-Algolia integration is opt-in: set SEARCH_TEST_ALGOLIA_APP_ID and - * SEARCH_TEST_ALGOLIA_API_KEY (a write-capable key) to a reachable app. Each run uses a unique - * index so leftover documents never collide; the suite cleans up after itself. - */ -const ALGOLIA_APP_ID = process.env.SEARCH_TEST_ALGOLIA_APP_ID; -const ALGOLIA_API_KEY = process.env.SEARCH_TEST_ALGOLIA_API_KEY; - -describe.skipIf(!ALGOLIA_APP_ID || !ALGOLIA_API_KEY)("algolia (live)", () => { - it("round-trips a document and maps id<->objectID", async () => { - const cb = fakeBreaker(true); - const index = new AlgoliaDocumentIndex( - { - appID: ALGOLIA_APP_ID!, - apiKey: ALGOLIA_API_KEY!, - indexName: `test-${randomUUID()}`, - }, - cb, - ); - try { - await index.index("doc-1", { id: "doc-1", name: "hello world" }); - // Algolia indexing is eventually consistent; a short wait keeps the test stable. - await new Promise((resolve) => setTimeout(resolve, 2_000)); - const results = await index.search("hello"); - expect(results.map((r) => r.id)).toContain("doc-1"); - } finally { - await index.wipe(); - } - }); -}); - -/** - * Live-Elasticsearch integration is opt-in: set SEARCH_TEST_ELASTICSEARCH_URL (e.g. - * http://localhost:9200) to a reachable cluster. Exercises the real readiness/ensure/index/ - * search path through the factory. - */ -const ELASTICSEARCH_URL = process.env.SEARCH_TEST_ELASTICSEARCH_URL; - -describe.skipIf(!ELASTICSEARCH_URL)("elasticsearch (live)", () => { - it("indexes and searches through the factory", async () => { - const index = await provideDocumentIndex(`test-${randomUUID()}`, { - provider: "elasticsearch", - elasticsearch: { address: ELASTICSEARCH_URL! }, - }); - await index.index("doc-1", { id: "doc-1", name: "hello world" }); - await new Promise((resolve) => setTimeout(resolve, 1_500)); - const results = await index.search("hello"); - expect(results.map((r) => r.id)).toContain("doc-1"); - }); -}); diff --git a/packages/search/src/document-index.ts b/packages/search/src/document-index.ts deleted file mode 100644 index 914e963..0000000 --- a/packages/search/src/document-index.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { PlatformError } from "@primandproper/errors"; - -/** - * The read half of a document index: full-text query returning the decoded documents (not - * scored hits). Generic over the stored document type `T`. Port of platform-go's - * `textsearch.IndexSearcher[T]`. - */ -export interface IndexSearcher { - /** Returns the documents matching `query`. A miss is an empty array, never a sentinel. */ - search(query: string): Promise; -} - -/** - * The write/admin half of a document index. Not generic — `index` takes an arbitrary - * serializable `value`, mirroring Go's `Index(ctx, id, value any)`. Port of - * platform-go's `textsearch.IndexManager`. - */ -export interface IndexManager { - /** Indexes (or replaces) `value` under `id`. */ - index(id: string, value: unknown): Promise; - /** Removes a document by id. */ - delete(id: string): Promise; - /** Clears the entire index. */ - wipe(): Promise; -} - -/** A document to bulk-index: an `id` and the arbitrary serializable `value` stored under it. */ -export interface BulkDocument { - id: string; - value: unknown; -} - -/** - * An {@link IndexManager} that can index many documents in one backend round trip (Elasticsearch - * `_bulk`, Algolia `saveObjects`, Typesense `import`) instead of N sequential `index()` calls. - * Not every manager supports it, so obtain one via {@link isBulkIndexManager}. - */ -export interface BulkIndexManager extends IndexManager { - /** Indexes (or replaces) every document in a single batched operation. */ - indexMany(documents: readonly BulkDocument[]): Promise; -} - -/** Narrows an {@link IndexManager} to a {@link BulkIndexManager} when the provider supports batching. */ -export function isBulkIndexManager(manager: IndexManager): manager is BulkIndexManager { - return typeof (manager as Partial).indexMany === "function"; -} - -/** - * A generic, provider-swappable text search index combining {@link IndexSearcher} and - * {@link IndexManager}. The faithful TypeScript port of platform-go's `textsearch.Index[T]`, - * distinct from this package's `TextIndex`: it indexes whole objects, returns decoded - * documents of type `T`, and exposes `wipe()` rather than scored hits. - */ -export interface DocumentIndex extends IndexSearcher, IndexManager {} - -/** - * The message published onto the indexing queue for a row that needs (re)indexing. Port of - * platform-go's `textsearch.IndexRequest`; the JSON shape is preserved exactly so it stays - * wire-compatible with the Go scheduler/worker. - */ -export interface IndexRequest { - /** Request identifier (`id`); assigned by the publisher when omitted. */ - id?: string; - /** The row to index (`rowID`). */ - rowID: string; - /** The index type this row belongs to (`type`). */ - type: string; - /** Optional test correlation id (`testID`); omitted when empty. */ - testID?: string; - /** Whether this request removes the row from the index rather than upserting it. */ - delete: boolean; -} - -/** The query-param key carrying the search text in HTTP requests. Port of `QueryKeySearch`. */ -export const QUERY_KEY_SEARCH = "q"; - -/** - * The number of hits returned when a caller doesn't specify a limit. Applied uniformly across - * every provider so the result-set size is an explicit, documented contract rather than each - * backend's own silent default (Typesense/ES 10, Algolia 20, memory unbounded). `IndexSearcher` - * providers (Elasticsearch, Algolia) take no per-call limit, so this is their fixed page size. - */ -export const DEFAULT_SEARCH_LIMIT = 10; - -/** - * Observability keys the providers attach to spans/logs, matching platform-go's - * `observability/keys` so traces line up across the two platforms. - */ -export const SEARCH_QUERY_KEY = "search_query"; -export const INDEX_NAME_KEY = "index.name"; -export const LENGTH_KEY = "length"; -export const ID_KEY = "id"; - -/** Thrown when an empty search query is provided. Port of `ErrEmptyQueryProvided`. */ -export class EmptyQueryError extends PlatformError { - constructor() { - super("search/empty-query", "empty search query provided"); - this.name = "EmptyQueryError"; - } -} - -/** - * Thrown when the circuit breaker is open and rejects a call. Replaces platform-go's - * `circuitbreaking.ErrCircuitBroken`, which our `@primandproper/circuitbreaking` package does - * not export as a sentinel. - */ -export class CircuitBrokenError extends PlatformError { - constructor() { - super("search/circuit-broken", "service circuit broken"); - this.name = "CircuitBrokenError"; - } -} diff --git a/packages/search/src/index.ts b/packages/search/src/index.ts deleted file mode 100644 index ac95cf2..0000000 --- a/packages/search/src/index.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { provideCircuitBreaker } from "@primandproper/circuitbreaking"; -import type { ObservabilityDeps } from "@primandproper/observability"; - -import { - DocumentIndexConfigSchema, - TextSearchConfigSchema, - type DocumentIndexConfigInput, - type TextSearchConfigInput, -} from "./config.js"; -import type { DocumentIndex } from "./document-index.js"; -import { AlgoliaDocumentIndex } from "./providers/algolia.node.js"; -import { ElasticsearchDocumentIndex } from "./providers/elasticsearch.node.js"; -import { MemoryTextIndex } from "./providers/memory-text.js"; -import { NoopDocumentIndex } from "./providers/noop-document-index.js"; -import { NoopTextIndex } from "./providers/noop.js"; -import { TypesenseTextIndex } from "./providers/typesense.node.js"; -import type { TextIndex } from "./text.js"; - -export * from "./text.js"; -export * from "./document-index.js"; -export * from "./config.js"; -export { MemoryTextIndex } from "./providers/memory-text.js"; -export { NoopTextIndex } from "./providers/noop.js"; -export { NoopDocumentIndex } from "./providers/noop-document-index.js"; -export { - AlgoliaDocumentIndex, - type AlgoliaDocumentIndexOptions, -} from "./providers/algolia.node.js"; -export { - ElasticsearchDocumentIndex, - type ElasticsearchDocumentIndexOptions, -} from "./providers/elasticsearch.node.js"; -export { - TypesenseTextIndex, - type TypesenseTextOptions, -} from "./providers/typesense.node.js"; - -/** - * Validates config and returns the matching {@link TextIndex}. Mirrors the Go platform's - * `Provide*`. Supports `memory` (default), `noop`, and `typesense`. - */ -export function provideTextIndex( - config?: TextSearchConfigInput, - deps?: ObservabilityDeps, -): TextIndex { - const cfg = TextSearchConfigSchema.parse(config ?? {}); - switch (cfg.provider) { - case "memory": - return new MemoryTextIndex(deps); - case "noop": - return new NoopTextIndex(); - case "typesense": - // superRefine guarantees this, but narrow for the type checker. - if (cfg.typesense === undefined) { - throw new Error("typesense config is required when provider is 'typesense'"); - } - return new TypesenseTextIndex( - { - apiKey: cfg.typesense.apiKey, - host: cfg.typesense.host, - port: cfg.typesense.port, - protocol: cfg.typesense.protocol, - collection: cfg.typesense.collection, - connectionTimeoutSeconds: cfg.typesense.connectionTimeoutSeconds, - }, - deps, - ); - } -} - -/** - * Validates config and returns the matching generic {@link DocumentIndex}. The faithful port of - * platform-go's `textsearch.ProvideIndex[T]`: defaults to `noop`, builds the circuit breaker - * that wraps every provider call, and is async because the `elasticsearch` provider polls the - * cluster for readiness and ensures its index before returning. `indexName` names the backing - * index (and the per-index observer, `search_`). - */ -export async function provideDocumentIndex( - indexName: string, - config?: DocumentIndexConfigInput, - deps?: ObservabilityDeps, -): Promise> { - const cfg = DocumentIndexConfigSchema.parse(config ?? {}); - const circuitBreaker = provideCircuitBreaker(cfg.circuitBreaker, deps); - switch (cfg.provider) { - case "algolia": - // superRefine guarantees this, but narrow for the type checker. - if (cfg.algolia === undefined) { - throw new Error("algolia config is required when provider is 'algolia'"); - } - return new AlgoliaDocumentIndex( - { appID: cfg.algolia.appID, apiKey: cfg.algolia.apiKey, indexName }, - circuitBreaker, - deps, - ); - case "elasticsearch": - if (cfg.elasticsearch === undefined) { - throw new Error( - "elasticsearch config is required when provider is 'elasticsearch'", - ); - } - return ElasticsearchDocumentIndex.create( - { - address: cfg.elasticsearch.address, - username: cfg.elasticsearch.username, - password: cfg.elasticsearch.password, - ...(cfg.elasticsearch.caCert !== undefined - ? { caCert: cfg.elasticsearch.caCert } - : {}), - ...(cfg.elasticsearch.indexOperationTimeoutMs !== undefined - ? { indexOperationTimeoutMs: cfg.elasticsearch.indexOperationTimeoutMs } - : {}), - indexName, - readinessAttempts: cfg.elasticsearch.readinessAttempts, - }, - circuitBreaker, - deps, - ); - case "noop": - return new NoopDocumentIndex(); - } -} diff --git a/packages/search/src/providers/algolia.node.ts b/packages/search/src/providers/algolia.node.ts deleted file mode 100644 index e913ca7..0000000 --- a/packages/search/src/providers/algolia.node.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { CircuitBreaker } from "@primandproper/circuitbreaking"; -import { wrap } from "@primandproper/errors"; -import { - makeObserver, - type Logger, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import { algoliasearch, type Algoliasearch } from "algoliasearch"; - -import { - type BulkDocument, - type BulkIndexManager, - CircuitBrokenError, - DEFAULT_SEARCH_LIMIT, - EmptyQueryError, - ID_KEY, - LENGTH_KEY, - SEARCH_QUERY_KEY, - type DocumentIndex, -} from "../document-index.js"; - -const OBJECT_ID_KEY = "objectID"; - -export interface AlgoliaDocumentIndexOptions { - /** Algolia application id. */ - appID: string; - /** Algolia write-capable API key. */ - apiKey: string; - /** The index this manager reads and writes. */ - indexName: string; -} - -/** - * A {@link DocumentIndex} backed by Algolia. Port of platform-go's algolia provider. Documents - * are keyed by Algolia's `objectID`, so on write the document's `id` is mapped to `objectID` - * and on read it is mapped back — keeping the `id`-centric contract callers expect. Every call - * is guarded by the circuit breaker (including `index`, which the Go source omitted — fixed - * here) and wrapped with context on failure. - */ -export class AlgoliaDocumentIndex implements DocumentIndex, BulkIndexManager { - readonly #client: Algoliasearch; - readonly #indexName: string; - readonly #cb: CircuitBreaker; - readonly #observer: Observer; - readonly #logger: Logger; - - constructor( - options: AlgoliaDocumentIndexOptions, - circuitBreaker: CircuitBreaker, - deps: ObservabilityDeps = {}, - ) { - this.#indexName = options.indexName; - this.#cb = circuitBreaker; - this.#observer = deps.observer ?? makeObserver(`search_${options.indexName}`, deps); - this.#logger = this.#observer.logger(); - this.#client = algoliasearch(options.appID, options.apiKey); - } - - index(id: string, value: unknown): Promise { - return this.#observer.run("Index", async (op) => { - if (!this.#cb.canProceed()) { - throw new CircuitBrokenError(); - } - - op.set(ID_KEY, id); - this.#logger.debug("adding to index"); - - const record = toRecord(value); - // Algolia keys documents by objectID; the platform keys by id. - record[OBJECT_ID_KEY] = id; - delete record.id; - - try { - await this.#client.saveObject({ indexName: this.#indexName, body: record }); - this.#cb.succeeded(); - } catch (error) { - this.#cb.failed(); - throw wrap(`algolia index failed for id ${id}`, error); - } - }); - } - - indexMany(documents: readonly BulkDocument[]): Promise { - return this.#observer.run("IndexMany", async (op) => { - if (documents.length === 0) { - return; - } - if (!this.#cb.canProceed()) { - throw new CircuitBrokenError(); - } - - op.set(LENGTH_KEY, documents.length); - this.#logger.debug("bulk adding to index"); - - // One `saveObjects` round trip: each doc keyed by objectID (Algolia's id), id mapped away. - const objects = documents.map(({ id, value }) => { - const record = toRecord(value); - record[OBJECT_ID_KEY] = id; - delete record.id; - return record; - }); - - try { - await this.#client.saveObjects({ indexName: this.#indexName, objects }); - this.#cb.succeeded(); - } catch (error) { - this.#cb.failed(); - throw wrap("algolia bulk index failed", error); - } - }); - } - - search(query: string): Promise { - return this.#observer.run("Search", async (op) => { - if (!this.#cb.canProceed()) { - throw new CircuitBrokenError(); - } - - op.set(SEARCH_QUERY_KEY, query); - - if (query === "") { - throw new EmptyQueryError(); - } - - let hits: Record[]; - try { - const response = await this.#client.searchSingleIndex({ - indexName: this.#indexName, - // Cap the result set explicitly; Algolia otherwise silently returns its first page of 20. - searchParams: { query, hitsPerPage: DEFAULT_SEARCH_LIMIT }, - }); - hits = response.hits; - this.#cb.succeeded(); - } catch (error) { - this.#cb.failed(); - throw wrap("algolia search failed", error); - } - - const results = hits.map(restoreId) as T[]; - op.set(LENGTH_KEY, results.length); - this.#logger.debug("search performed"); - return results; - }); - } - - delete(id: string): Promise { - return this.#observer.run("Delete", async (op) => { - if (!this.#cb.canProceed()) { - throw new CircuitBrokenError(); - } - - op.set(ID_KEY, id); - - try { - await this.#client.deleteObject({ indexName: this.#indexName, objectID: id }); - this.#cb.succeeded(); - } catch (error) { - this.#cb.failed(); - throw wrap(`algolia delete failed for id ${id}`, error); - } - - this.#logger.debug("removed from index"); - }); - } - - wipe(): Promise { - return this.#observer.run("Wipe", async () => { - if (!this.#cb.canProceed()) { - throw new CircuitBrokenError(); - } - - try { - await this.#client.clearObjects({ indexName: this.#indexName }); - this.#cb.succeeded(); - } catch (error) { - this.#cb.failed(); - throw wrap("algolia wipe failed", error); - } - }); - } -} - -/** JSON round-trips `value` into a mutable string-keyed record, mirroring Go's marshal/unmarshal. */ -function toRecord(value: unknown): Record { - return JSON.parse(JSON.stringify(value)) as Record; -} - -/** Restores an Algolia hit's `objectID` back to the platform's `id` field. */ -function restoreId(hit: Record): Record { - const { [OBJECT_ID_KEY]: objectID, ...rest } = hit; - if (objectID !== undefined) { - rest.id = objectID; - } - return rest; -} diff --git a/packages/search/src/providers/elasticsearch.node.test.ts b/packages/search/src/providers/elasticsearch.node.test.ts deleted file mode 100644 index 672ab06..0000000 --- a/packages/search/src/providers/elasticsearch.node.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { CircuitBreaker } from "@primandproper/circuitbreaking"; -import { describe, expect, it, vi } from "vitest"; - -import { CircuitBrokenError } from "../document-index.js"; - -import { ElasticsearchDocumentIndex } from "./elasticsearch.node.js"; - -/** - * Shared, hoisted mock state for a fake `@elastic/elasticsearch` `Client`. `vi.mock` is hoisted - * above the imports, so its factory can only close over state created with `vi.hoisted`. Recording - * the index name passed to each operation lets us assert exists/create/delete all address the - * same (lowercased) index, and steering `deleteError` exercises the delete-of-missing path — both - * offline, since the real client opens a live connection. - */ -const mock = vi.hoisted( - (): { - existsResult: boolean; - deleteError: Error | undefined; - existsIndex: string[]; - createIndex: string[]; - deleteIndex: string[]; - } => ({ - existsResult: false, - deleteError: undefined, - existsIndex: [], - createIndex: [], - deleteIndex: [], - }), -); - -vi.mock("@elastic/elasticsearch", () => { - class Client { - info(): Promise { - return Promise.resolve({}); - } - indices = { - exists: ({ index }: { index: string }): Promise => { - mock.existsIndex.push(index); - return Promise.resolve(mock.existsResult); - }, - create: ({ index }: { index: string }): Promise => { - mock.createIndex.push(index); - return Promise.resolve({}); - }, - }; - delete({ index }: { index: string }): Promise { - mock.deleteIndex.push(index); - if (mock.deleteError !== undefined) { - return Promise.reject(mock.deleteError); - } - return Promise.resolve({}); - } - } - return { Client }; -}); - -interface Doc { - id: string; - name: string; -} - -/** A circuit breaker whose calls are recorded so we can assert the breaker was (not) tripped. */ -function fakeBreaker(canProceed: boolean): CircuitBreaker & { - canProceed: ReturnType; - succeeded: ReturnType; - failed: ReturnType; -} { - return { - canProceed: vi.fn(() => canProceed), - succeeded: vi.fn(), - failed: vi.fn(), - }; -} - -/** Builds an Elasticsearch-shaped error carrying an HTTP status code. */ -function statusError(statusCode: number): Error { - return Object.assign(new Error(`elasticsearch responded ${String(statusCode)}`), { - statusCode, - }); -} - -function resetMock(): void { - mock.existsResult = false; - mock.deleteError = undefined; - mock.existsIndex = []; - mock.createIndex = []; - mock.deleteIndex = []; -} - -// SRCH-2: ES lowercases index names, so exists/create/delete must all address the same normalized -// name (the old code lowercased only on `create`, leaving exists/delete pointed at a phantom index), -// and a 404 on delete-of-missing must be a no-op success rather than tripping the circuit breaker. -describe("ElasticsearchDocumentIndex (SRCH-2, mocked client)", () => { - it("normalizes the index name so exists/create/delete address the same lowercased index", async () => { - resetMock(); - mock.existsResult = false; // force the create branch so we observe its index name too - const index = await ElasticsearchDocumentIndex.create( - { address: "http://es", indexName: "MixedCase-Index", readinessAttempts: 1 }, - fakeBreaker(true), - ); - - await index.delete("doc-1"); - - expect(mock.existsIndex).toEqual(["mixedcase-index"]); - expect(mock.createIndex).toEqual(["mixedcase-index"]); - expect(mock.deleteIndex).toEqual(["mixedcase-index"]); - }); - - it("treats a 404 on delete as a no-op success without tripping the breaker", async () => { - resetMock(); - mock.existsResult = true; // index already exists; skip create - const cb = fakeBreaker(true); - const index = await ElasticsearchDocumentIndex.create( - { address: "http://es", indexName: "idx", readinessAttempts: 1 }, - cb, - ); - cb.succeeded.mockClear(); - cb.failed.mockClear(); - mock.deleteError = statusError(404); // a missing document - - await expect(index.delete("missing")).resolves.toBeUndefined(); - expect(cb.failed).not.toHaveBeenCalled(); // a miss is not a failure - expect(cb.succeeded).toHaveBeenCalled(); - }); - - it("still trips the breaker on a non-404 delete failure", async () => { - resetMock(); - mock.existsResult = true; - const cb = fakeBreaker(true); - const index = await ElasticsearchDocumentIndex.create( - { address: "http://es", indexName: "idx", readinessAttempts: 1 }, - cb, - ); - cb.succeeded.mockClear(); - cb.failed.mockClear(); - mock.deleteError = statusError(500); - - await expect(index.delete("boom")).rejects.toBeDefined(); - expect(cb.failed).toHaveBeenCalled(); - }); - - it("rejects delete with CircuitBrokenError when the breaker is open (no SDK call)", async () => { - resetMock(); - // Construct directly (skip create()'s readiness handshake) with an open breaker. - const index = new ElasticsearchDocumentIndex( - { address: "http://es", indexName: "idx", readinessAttempts: 1 }, - fakeBreaker(false), - ); - await expect(index.delete("x")).rejects.toBeInstanceOf(CircuitBrokenError); - expect(mock.deleteIndex).toEqual([]); // breaker open: the SDK is never reached - }); -}); diff --git a/packages/search/src/providers/elasticsearch.node.ts b/packages/search/src/providers/elasticsearch.node.ts deleted file mode 100644 index f31e23b..0000000 --- a/packages/search/src/providers/elasticsearch.node.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { Client, type ClientOptions } from "@elastic/elasticsearch"; -import type { CircuitBreaker } from "@primandproper/circuitbreaking"; -import { PlatformError, wrap } from "@primandproper/errors"; -import { - makeObserver, - type Logger, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { - type BulkDocument, - type BulkIndexManager, - CircuitBrokenError, - DEFAULT_SEARCH_LIMIT, - EmptyQueryError, - ID_KEY, - INDEX_NAME_KEY, - LENGTH_KEY, - SEARCH_QUERY_KEY, - type DocumentIndex, -} from "../document-index.js"; - -export interface ElasticsearchDocumentIndexOptions { - /** Node address, e.g. `http://localhost:9200`. */ - address: string; - /** Basic-auth username; omit/empty for none. */ - username?: string; - /** Basic-auth password; omit/empty for none. */ - password?: string; - /** PEM CA certificate for TLS verification. */ - caCert?: string; - /** Per-index-operation timeout in milliseconds. */ - indexOperationTimeoutMs?: number; - /** The index this manager reads and writes. */ - indexName: string; - /** How many times to poll the cluster for readiness before giving up. */ - readinessAttempts: number; -} - -/** Minimal shape of the Elasticsearch search response we decode. Port of `queries.go`. */ -interface SearchResponseBody { - hits?: { - hits?: { _id?: string; _source?: T }[]; - }; -} - -/** - * A {@link DocumentIndex} backed by Elasticsearch. Port of platform-go's elasticsearch - * provider. Construct via {@link ElasticsearchDocumentIndex.create}, which polls the cluster - * for readiness and ensures the backing index exists before returning. Every external call is - * circuit-breaker–guarded and wrapped with context on failure. - * - * Two deliberate divergences from the Go source: `search` issues a real `query_string` query - * (the Go version built an empty `bool/should` that matched nothing), and `wipe` throws an - * explicit unimplemented error (as the Go version also did). - */ -export class ElasticsearchDocumentIndex implements DocumentIndex, BulkIndexManager { - readonly #client: Client; - readonly #indexName: string; - readonly #indexOperationTimeoutMs: number | undefined; - readonly #cb: CircuitBreaker; - readonly #observer: Observer; - readonly #logger: Logger; - - /** - * Builds the client and observer without any I/O — the `new Client(...)` connection is lazy. - * Prefer {@link ElasticsearchDocumentIndex.create}, which also waits for readiness and ensures - * the index; use the constructor directly only when you want to skip that handshake (e.g. tests). - */ - constructor( - options: ElasticsearchDocumentIndexOptions, - circuitBreaker: CircuitBreaker, - deps: ObservabilityDeps = {}, - ) { - // Elasticsearch lowercases index names; normalize once so exists/create/index/search/delete - // all address the same index (they previously mixed the raw and lowercased name). - this.#indexName = options.indexName.toLowerCase(); - this.#indexOperationTimeoutMs = options.indexOperationTimeoutMs; - this.#cb = circuitBreaker; - this.#observer = deps.observer ?? makeObserver(`search_${options.indexName}`, deps); - this.#logger = this.#observer.logger(); - this.#client = new Client(buildClientOptions(options)); - } - - /** - * Builds the index, waits for the cluster to become reachable, ensures the index exists, and - * returns the ready-to-use manager. Mirrors Go's `ProvideIndexManager` (readiness poll + - * `ensureIndices`). - */ - static async create( - options: ElasticsearchDocumentIndexOptions, - circuitBreaker: CircuitBreaker, - deps: ObservabilityDeps = {}, - ): Promise> { - const index = new ElasticsearchDocumentIndex(options, circuitBreaker, deps); - await index.#awaitReady(options.readinessAttempts); - await index.#ensureIndices(); - return index; - } - - async #awaitReady(maxAttempts: number): Promise { - for (let attempt = 1; ; attempt++) { - try { - await this.#client.info(); - return; - } catch (error) { - if (attempt >= maxAttempts) { - throw wrap("elasticsearch not ready", error); - } - this.#logger.debug("ping failed, waiting for elasticsearch"); - await delay(1_000); - } - } - } - - #ensureIndices(): Promise { - return this.#observer.run("EnsureIndices", async (op) => { - op.set(INDEX_NAME_KEY, this.#indexName); - - if (!this.#cb.canProceed()) { - throw new CircuitBrokenError(); - } - - try { - const exists = await this.#client.indices.exists({ index: this.#indexName }); - if (!exists) { - await this.#client.indices.create({ index: this.#indexName }); - } - this.#cb.succeeded(); - } catch (error) { - this.#cb.failed(); - throw wrap("checking index existence", error); - } - }); - } - - index(id: string, value: unknown): Promise { - return this.#observer.run("Index", async (op) => { - if (!this.#cb.canProceed()) { - throw new CircuitBrokenError(); - } - - op.set(ID_KEY, id).set(INDEX_NAME_KEY, this.#indexName); - this.#logger.debug("adding to index"); - - try { - await this.#client.index({ - index: this.#indexName, - id, - document: value as Record, - ...(this.#indexOperationTimeoutMs !== undefined - ? { timeout: `${String(this.#indexOperationTimeoutMs)}ms` } - : {}), - }); - this.#cb.succeeded(); - } catch (error) { - this.#cb.failed(); - throw wrap("indexing value", error); - } - }); - } - - indexMany(documents: readonly BulkDocument[]): Promise { - return this.#observer.run("IndexMany", async (op) => { - if (documents.length === 0) { - return; - } - if (!this.#cb.canProceed()) { - throw new CircuitBrokenError(); - } - - op.set(INDEX_NAME_KEY, this.#indexName).set(LENGTH_KEY, documents.length); - this.#logger.debug("bulk adding to index"); - - // One `_bulk` round trip: an action line + a source line per document. - const operations = documents.flatMap(({ id, value }) => [ - { index: { _index: this.#indexName, _id: id } }, - value as Record, - ]); - - try { - const response = await this.#client.bulk({ - operations, - ...(this.#indexOperationTimeoutMs !== undefined - ? { timeout: `${String(this.#indexOperationTimeoutMs)}ms` } - : {}), - }); - if (response.errors) { - throw new PlatformError( - "search/bulk-failed", - "elasticsearch bulk had item errors", - ); - } - this.#cb.succeeded(); - } catch (error) { - this.#cb.failed(); - throw wrap("bulk indexing values", error); - } - }); - } - - search(query: string): Promise { - return this.#observer.run("Search", async (op) => { - if (!this.#cb.canProceed()) { - throw new CircuitBrokenError(); - } - - op.set(SEARCH_QUERY_KEY, query); - - if (query === "") { - throw new EmptyQueryError(); - } - - let body: SearchResponseBody; - try { - body = await this.#client.search({ - index: this.#indexName, - query: { query_string: { query } }, - // Cap the result set explicitly; ES otherwise silently returns its own default of 10. - size: DEFAULT_SEARCH_LIMIT, - }); - this.#cb.succeeded(); - } catch (error) { - this.#cb.failed(); - throw wrap("querying elasticsearch", error); - } - - const results: T[] = []; - for (const hit of body.hits?.hits ?? []) { - if (hit._source !== undefined) { - results.push(hit._source); - } - } - - op.set(INDEX_NAME_KEY, this.#indexName).set(LENGTH_KEY, results.length); - return results; - }); - } - - delete(id: string): Promise { - return this.#observer.run("Delete", async (op) => { - if (!this.#cb.canProceed()) { - throw new CircuitBrokenError(); - } - - op.set(ID_KEY, id).set(INDEX_NAME_KEY, this.#indexName); - - try { - await this.#client.delete({ index: this.#indexName, id }); - this.#cb.succeeded(); - } catch (error) { - // Deleting an unknown id is a no-op, not a failure — the server responded, so this must - // not trip the breaker (matches the Typesense sibling's ObjectNotFound handling). - if (isElasticNotFound(error)) { - this.#cb.succeeded(); - this.#logger.debug("delete of missing document is a no-op"); - return; - } - this.#cb.failed(); - throw wrap("deleting from elasticsearch", error); - } - - this.#logger.debug("removed from index"); - }); - } - - wipe(): Promise { - return Promise.reject( - new PlatformError("search/unimplemented", "wipe is unimplemented"), - ); - } -} - -function buildClientOptions(options: ElasticsearchDocumentIndexOptions): ClientOptions { - const clientOptions: ClientOptions = { node: options.address, maxRetries: 10 }; - if (options.username || options.password) { - clientOptions.auth = { - username: options.username ?? "", - password: options.password ?? "", - }; - } - if (options.caCert !== undefined) { - clientOptions.tls = { ca: options.caCert }; - } - return clientOptions; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Whether an error from the Elasticsearch client represents a 404 (index or document not found). - * The client raises a `ResponseError` carrying the HTTP status on `statusCode` (and `meta.statusCode`). - */ -export function isElasticNotFound(error: unknown): boolean { - if (typeof error !== "object" || error === null) { - return false; - } - const statusCode = (error as { statusCode?: unknown }).statusCode; - const metaStatusCode = (error as { meta?: { statusCode?: unknown } }).meta?.statusCode; - return statusCode === 404 || metaStatusCode === 404; -} diff --git a/packages/search/src/providers/memory-text.ts b/packages/search/src/providers/memory-text.ts deleted file mode 100644 index 8accbe0..0000000 --- a/packages/search/src/providers/memory-text.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { - makeObserver, - type Logger, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { DEFAULT_SEARCH_LIMIT } from "../document-index.js"; -import type { BulkTextIndex, TextDocument, TextHit, TextSearchOptions } from "../text.js"; - -const o11yName = "search"; - -interface StoredTextDocument { - readonly text: string; - readonly terms: Map; - readonly metadata?: Record; -} - -const WORD = /[a-z0-9]+/g; - -function tokenize(text: string): string[] { - return text.toLowerCase().match(WORD) ?? []; -} - -function termFrequencies(tokens: string[]): Map { - const counts = new Map(); - for (const token of tokens) { - counts.set(token, (counts.get(token) ?? 0) + 1); - } - return counts; -} - -/** - * An in-memory {@link TextIndex}. Scores documents by query-term overlap weighted by term - * frequency — genuinely usable for small datasets and tests, not a production search engine. - * The default text provider. - */ -export class MemoryTextIndex implements BulkTextIndex { - readonly #docs = new Map(); - readonly #observer: Observer; - readonly #logger: Logger; - - constructor(deps: ObservabilityDeps = {}) { - this.#observer = deps.observer ?? makeObserver(o11yName, deps); - this.#logger = this.#observer.logger(); - } - - index(doc: TextDocument): Promise { - this.#store(doc); - return Promise.resolve(); - } - - indexMany(docs: readonly TextDocument[]): Promise { - for (const doc of docs) { - this.#store(doc); - } - return Promise.resolve(); - } - - #store(doc: TextDocument): void { - const stored: StoredTextDocument = { - text: doc.text, - terms: termFrequencies(tokenize(doc.text)), - ...(doc.metadata !== undefined ? { metadata: doc.metadata } : {}), - }; - this.#docs.set(doc.id, stored); - } - - search(query: string, opts: TextSearchOptions = {}): Promise { - const queryTerms = tokenize(query); - if (queryTerms.length === 0) { - this.#logger.debug("empty text query"); - return Promise.resolve([]); - } - - const hits: TextHit[] = []; - for (const [id, doc] of this.#docs) { - let score = 0; - for (const term of queryTerms) { - score += doc.terms.get(term) ?? 0; - } - if (score > 0) { - hits.push({ - id, - score, - ...(doc.metadata !== undefined ? { metadata: doc.metadata } : {}), - }); - } - } - - hits.sort((a, b) => b.score - a.score); - - const limit = opts.limit ?? DEFAULT_SEARCH_LIMIT; - return Promise.resolve(hits.slice(0, limit)); - } - - delete(id: string): Promise { - this.#docs.delete(id); - return Promise.resolve(); - } - - ping(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/search/src/providers/noop-document-index.ts b/packages/search/src/providers/noop-document-index.ts deleted file mode 100644 index 79f8344..0000000 --- a/packages/search/src/providers/noop-document-index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { BulkIndexManager, DocumentIndex } from "../document-index.js"; - -/** - * A {@link DocumentIndex} that stores nothing; every search is an empty array. The factory - * default, matching platform-go where an unknown/empty provider falls back to noop. Universal: - * no Node built-ins, no circuit breaker, no observability. Also a {@link BulkIndexManager} no-op. - */ -export class NoopDocumentIndex implements DocumentIndex, BulkIndexManager { - search(): Promise { - return Promise.resolve([]); - } - - index(): Promise { - return Promise.resolve(); - } - - indexMany(): Promise { - return Promise.resolve(); - } - - delete(): Promise { - return Promise.resolve(); - } - - wipe(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/search/src/providers/noop.ts b/packages/search/src/providers/noop.ts deleted file mode 100644 index 4d0fa79..0000000 --- a/packages/search/src/providers/noop.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { BulkTextIndex, TextHit } from "../text.js"; - -/** A {@link TextIndex} that indexes nothing; every search is empty. */ -export class NoopTextIndex implements BulkTextIndex { - index(): Promise { - return Promise.resolve(); - } - - indexMany(): Promise { - return Promise.resolve(); - } - - search(): Promise { - return Promise.resolve([]); - } - - delete(): Promise { - return Promise.resolve(); - } - - ping(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/search/src/providers/typesense.node.ts b/packages/search/src/providers/typesense.node.ts deleted file mode 100644 index ddbff07..0000000 --- a/packages/search/src/providers/typesense.node.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { - makeObserver, - type Logger, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; -import { Client, Errors } from "typesense"; - -import { - DEFAULT_SEARCH_LIMIT, - ID_KEY, - INDEX_NAME_KEY, - LENGTH_KEY, - SEARCH_QUERY_KEY, -} from "../document-index.js"; -import type { BulkTextIndex, TextDocument, TextHit, TextSearchOptions } from "../text.js"; - -/** - * The Typesense document shape this provider stores. `text` is the searchable field; - * `metadata` is serialized to JSON so arbitrary objects survive a round-trip without - * forcing nested-field indexing on the collection. - */ -interface TypesenseTextDocument { - id: string; - text: string; - metadata?: string; -} - -export interface TypesenseTextOptions { - /** Typesense API key. */ - apiKey: string; - /** Connection host, e.g. `localhost`. */ - host: string; - /** Connection port. */ - port: number; - /** Connection protocol, `http` or `https`. */ - protocol: string; - /** Collection that backs this index; created on first use if absent. */ - collection: string; - /** Per-request connection timeout in seconds. */ - connectionTimeoutSeconds?: number; -} - -/** - * A {@link TextIndex} backed by a Typesense server. Documents are written into a single - * collection whose `text` field is searched; the relevance `score` is Typesense's - * `text_match`. The collection is created lazily on the first write or {@link ping}, so a - * sync `provideTextIndex` can hand one back without awaiting I/O. Every SDK error is - * rethrown wrapped with context; a delete of an unknown id is a no-op, and a miss is an - * empty array rather than a sentinel. - */ -export class TypesenseTextIndex implements BulkTextIndex { - readonly #client: Client; - readonly #collection: string; - readonly #observer: Observer; - readonly #logger: Logger; - #ensured: Promise | undefined; - - constructor(options: TypesenseTextOptions, deps: ObservabilityDeps = {}) { - this.#collection = options.collection; - this.#observer = deps.observer ?? makeObserver(`search_${options.collection}`, deps); - this.#logger = this.#observer.logger(); - this.#client = new Client({ - apiKey: options.apiKey, - nodes: [ - { - host: options.host, - port: options.port, - protocol: options.protocol, - }, - ], - ...(options.connectionTimeoutSeconds !== undefined - ? { connectionTimeoutSeconds: options.connectionTimeoutSeconds } - : {}), - }); - } - - /** Creates the backing collection once, memoizing the in-flight promise. */ - #ensureCollection(): Promise { - return (this.#ensured ??= this.#createCollection()); - } - - #createCollection(): Promise { - return this.#observer.run("EnsureCollection", async (op) => { - op.set(INDEX_NAME_KEY, this.#collection); - - try { - const exists = await this.#client.collections(this.#collection).exists(); - if (exists) { - return; - } - await this.#client.collections().create({ - name: this.#collection, - fields: [ - { name: "id", type: "string" }, - { name: "text", type: "string" }, - { name: "metadata", type: "string", optional: true, index: false }, - ], - }); - } catch (error) { - // Another caller may have raced us to create it; tolerate that, surface the rest. - if (error instanceof Errors.ObjectAlreadyExists) { - return; - } - this.#ensured = undefined; - throw op.error(error, "typesense ensure collection failed"); - } - }); - } - - index(doc: TextDocument): Promise { - return this.#observer.run("Index", async (op) => { - op.set(ID_KEY, doc.id).set(INDEX_NAME_KEY, this.#collection); - await this.#ensureCollection(); - - const document: TypesenseTextDocument = { - id: doc.id, - text: doc.text, - ...(doc.metadata !== undefined ? { metadata: JSON.stringify(doc.metadata) } : {}), - }; - - this.#logger.debug("adding to index"); - - try { - await this.#client - .collections(this.#collection) - .documents() - .upsert(document); - } catch (error) { - throw op.error(error, "typesense index failed"); - } - }); - } - - indexMany(docs: readonly TextDocument[]): Promise { - return this.#observer.run("IndexMany", async (op) => { - op.set(INDEX_NAME_KEY, this.#collection).set(LENGTH_KEY, docs.length); - if (docs.length === 0) { - return; - } - await this.#ensureCollection(); - - const documents: TypesenseTextDocument[] = docs.map((doc) => ({ - id: doc.id, - text: doc.text, - ...(doc.metadata !== undefined ? { metadata: JSON.stringify(doc.metadata) } : {}), - })); - - this.#logger.debug("bulk adding to index"); - - try { - // One `import` round trip instead of N upserts. - await this.#client - .collections(this.#collection) - .documents() - .import(documents, { action: "upsert" }); - } catch (error) { - throw op.error(error, "typesense bulk index failed"); - } - }); - } - - search(query: string, opts: TextSearchOptions = {}): Promise { - return this.#observer.run("Search", async (op) => { - op.set(SEARCH_QUERY_KEY, query).set(INDEX_NAME_KEY, this.#collection); - await this.#ensureCollection(); - - try { - const response = await this.#client - .collections(this.#collection) - .documents() - .search({ - q: query, - query_by: "text", - per_page: opts.limit ?? DEFAULT_SEARCH_LIMIT, - }); - - const hits = response.hits ?? []; - const results = hits.map((hit) => toTextHit(hit.document, hit.text_match)); - op.set(LENGTH_KEY, results.length); - this.#logger.debug("search performed"); - return results; - } catch (error) { - throw op.error(error, "typesense search failed"); - } - }); - } - - delete(id: string): Promise { - return this.#observer.run("Delete", async (op) => { - op.set(ID_KEY, id).set(INDEX_NAME_KEY, this.#collection); - await this.#ensureCollection(); - - try { - await this.#client.collections(this.#collection).documents(id).delete(); - } catch (error) { - // A miss is not an error: deleting an unknown id is a no-op. - if (error instanceof Errors.ObjectNotFound) { - return; - } - throw op.error(error, "typesense delete failed"); - } - - this.#logger.debug("removed from index"); - }); - } - - async ping(): Promise { - await this.#observer.run("Ping", async (op) => { - op.set(INDEX_NAME_KEY, this.#collection); - - try { - const health = await this.#client.health.retrieve(); - if (!health.ok) { - throw new Error("typesense reported unhealthy"); - } - } catch (error) { - throw op.error(error, "typesense ping failed"); - } - }); - await this.#ensureCollection(); - } -} - -/** Maps a stored Typesense document back to a {@link TextHit}. */ -function toTextHit(document: TypesenseTextDocument, score: number): TextHit { - const metadata = parseMetadata(document.metadata); - return { - id: document.id, - score, - ...(metadata !== undefined ? { metadata } : {}), - }; -} - -/** Deserializes stored metadata, dropping it silently if it is not a JSON object. */ -function parseMetadata(raw: string | undefined): Record | undefined { - if (raw === undefined) { - return undefined; - } - try { - const parsed: unknown = JSON.parse(raw); - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - return parsed as Record; - } - } catch { - // Stored value was not valid JSON; treat as absent rather than throwing on read. - } - return undefined; -} diff --git a/packages/search/src/search.test.ts b/packages/search/src/search.test.ts deleted file mode 100644 index e232f88..0000000 --- a/packages/search/src/search.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import { makeRecordingObserver } from "@primandproper/observability"; -import { describe, expect, it } from "vitest"; - -import { ID_KEY, INDEX_NAME_KEY, SEARCH_QUERY_KEY } from "./document-index.js"; -import { isElasticNotFound } from "./providers/elasticsearch.node.js"; -import { MemoryTextIndex } from "./providers/memory-text.js"; -import { NoopTextIndex } from "./providers/noop.js"; -import { TypesenseTextIndex } from "./providers/typesense.node.js"; - -import { - DEFAULT_SEARCH_LIMIT, - isBulkTextIndex, - provideTextIndex, - type TextIndex, -} from "./index.js"; - -/** - * Live-Typesense integration is opt-in: set SEARCH_TEST_TYPESENSE_URL (e.g. - * http://localhost:8108) and SEARCH_TEST_TYPESENSE_API_KEY to a reachable server to run the - * text conformance suite against it. The default offline run skips it and stays green. Each - * instance gets a unique collection so concurrent/leftover docs never collide. - */ -const TYPESENSE_URL = process.env.SEARCH_TEST_TYPESENSE_URL; -const TYPESENSE_API_KEY = process.env.SEARCH_TEST_TYPESENSE_API_KEY; - -async function seedText(index: TextIndex): Promise { - await index.index({ id: "a", text: "the quick brown fox" }); - await index.index({ id: "b", text: "a lazy brown dog sleeps" }); - await index.index({ id: "c", text: "quick quick fox runs quick" }); -} - -/** - * Provider-agnostic conformance suite. Running the same assertions against multiple providers - * proves the `TextIndex` interface is implementation-independent. Real search engines rank by - * their own scoring, so this asserts only behavior every backend must share — membership, - * limits, metadata round-trips, deletes, and misses — not a specific ordering. - */ -function conformance(name: string, make: () => TextIndex): void { - describe(name, () => { - it("returns matching documents", async () => { - const index = make(); - await seedText(index); - - const ids = (await index.search("quick fox")).map((h) => h.id); - expect(ids).toContain("a"); - expect(ids).toContain("c"); - }); - - it("respects the limit", async () => { - const index = make(); - await seedText(index); - - const hits = await index.search("brown quick fox", { limit: 1 }); - expect(hits).toHaveLength(1); - }); - - it("returns metadata on hits", async () => { - const index = make(); - await index.index({ id: "a", text: "brown fox", metadata: { tag: "x" } }); - - const hits = await index.search("fox"); - expect(hits[0]?.metadata).toEqual({ tag: "x" }); - }); - - it("removes a document on delete", async () => { - const index = make(); - await seedText(index); - await index.delete("c"); - - const ids = (await index.search("quick fox")).map((h) => h.id); - expect(ids).not.toContain("c"); - }); - - it("deletes an unknown id as a no-op", async () => { - const index = make(); - await expect(index.delete("missing")).resolves.toBeUndefined(); - }); - - it("returns nothing for an unmatched query", async () => { - const index = make(); - await seedText(index); - - expect(await index.search("elephant")).toEqual([]); - }); - - it("pings without throwing", async () => { - await expect(make().ping()).resolves.toBeUndefined(); - }); - }); -} - -const typesenseIndex = (): TypesenseTextIndex => { - const url = new URL(TYPESENSE_URL ?? "http://localhost:8108"); - return new TypesenseTextIndex({ - apiKey: TYPESENSE_API_KEY ?? "xyz", - host: url.hostname, - port: Number(url.port || (url.protocol === "https:" ? 443 : 80)), - protocol: url.protocol.replace(":", ""), - collection: `searchtest_${randomUUID().replaceAll("-", "")}`, - }); -}; - -describe.skipIf(!TYPESENSE_URL || !TYPESENSE_API_KEY)("TypesenseTextIndex (live)", () => { - conformance("TypesenseTextIndex", typesenseIndex); -}); - -/** - * Instrumentation parity with the ElasticSearch/Algolia siblings (audit INST-2), asserted - * offline: every I/O op opens an observer span, fans the index/id/query onto it, and routes a - * failure through `op.error` so the error object itself is recorded. The point at an unreachable - * server, so the first SDK call (the lazy collection ensure) fails and exercises the error path - * without a live Typesense. - */ -describe("TypesenseTextIndex observability", () => { - const offlineIndex = ( - observer: ReturnType, - ): TypesenseTextIndex => - new TypesenseTextIndex( - { - apiKey: "xyz", - host: "127.0.0.1", - port: 59999, - protocol: "http", - collection: "obs_collection", - connectionTimeoutSeconds: 1, - }, - { observer }, - ); - - it("opens an operation span with the id and index attributes", async () => { - const observer = makeRecordingObserver(); - const index = offlineIndex(observer); - - await expect(index.index({ id: "doc-1", text: "hello" })).rejects.toBeDefined(); - - expect(observer.forOperation("Index").length).toBeGreaterThan(0); - expect(observer.data()[ID_KEY]).toBe("doc-1"); - expect(observer.data()[INDEX_NAME_KEY]).toBe("obs_collection"); - }); - - it("records a failure with the error object and an error-outcome run", async () => { - const observer = makeRecordingObserver(); - const index = offlineIndex(observer); - - await expect(index.index({ id: "doc-1", text: "hello" })).rejects.toBeDefined(); - - // The dark-sibling bug was logging a bare string with neither the error nor an id; now the - // failure is routed through op.error, capturing the error object itself. - expect(observer.errors.length).toBeGreaterThan(0); - expect(observer.errors[0]?.err).toBeDefined(); - expect(observer.errors.some((e) => e.description.includes("typesense"))).toBe(true); - expect(observer.runs.some((r) => r.outcome === "error")).toBe(true); - }); - - it("observes the query on a failing search", async () => { - const observer = makeRecordingObserver(); - const index = offlineIndex(observer); - - await expect(index.search("brown fox")).rejects.toBeDefined(); - - expect(observer.forOperation("Search").length).toBeGreaterThan(0); - expect(observer.data()[SEARCH_QUERY_KEY]).toBe("brown fox"); - }); -}); - -describe("MemoryTextIndex", () => { - it("ranks the most relevant document first", async () => { - const index = new MemoryTextIndex(); - await seedText(index); - - const hits = await index.search("quick fox"); - expect(hits[0]?.id).toBe("c"); - expect(hits.map((h) => h.id)).toContain("a"); - }); - - // PERF-5: the bulk seam indexes many docs and is discoverable via the guard. - it("indexes many documents via the bulk seam", async () => { - const index = new MemoryTextIndex(); - expect(isBulkTextIndex(index)).toBe(true); - - await index.indexMany([ - { id: "a", text: "the quick brown fox" }, - { id: "b", text: "a lazy dog" }, - ]); - - expect((await index.search("fox"))[0]?.id).toBe("a"); - expect((await index.search("dog"))[0]?.id).toBe("b"); - }); - - it("respects the limit", async () => { - const index = new MemoryTextIndex(); - await seedText(index); - - const hits = await index.search("brown quick fox", { limit: 1 }); - expect(hits).toHaveLength(1); - }); - - // SRCH-1: an omitted limit caps at DEFAULT_SEARCH_LIMIT, not "unbounded". - it("caps results at the default limit when none is given", async () => { - const index = new MemoryTextIndex(); - for (let i = 0; i < 25; i++) { - await index.index({ id: `d${String(i)}`, text: "fox" }); - } - - expect(await index.search("fox")).toHaveLength(DEFAULT_SEARCH_LIMIT); - // An explicit limit still overrides the default. - expect(await index.search("fox", { limit: 3 })).toHaveLength(3); - }); - - it("returns metadata on hits", async () => { - const index = new MemoryTextIndex(); - await index.index({ id: "a", text: "brown fox", metadata: { tag: "x" } }); - - const hits = await index.search("fox"); - expect(hits[0]?.metadata).toEqual({ tag: "x" }); - }); - - it("removes a document on delete", async () => { - const index = new MemoryTextIndex(); - await seedText(index); - await index.delete("c"); - - const hits = await index.search("quick fox"); - expect(hits.map((h) => h.id)).not.toContain("c"); - expect(hits[0]?.id).toBe("a"); - }); - - it("returns nothing for an unmatched query", async () => { - const index = new MemoryTextIndex(); - await seedText(index); - - expect(await index.search("elephant")).toEqual([]); - }); - - it("pings without throwing", async () => { - await expect(new MemoryTextIndex().ping()).resolves.toBeUndefined(); - }); -}); - -describe("NoopTextIndex", () => { - it("returns no hits", async () => { - const index: TextIndex = new NoopTextIndex(); - await index.index({ id: "a", text: "brown fox" }); - expect(await index.search("fox")).toEqual([]); - }); -}); - -describe("provideTextIndex", () => { - it("defaults to the memory provider", () => { - expect(provideTextIndex(undefined, {})).toBeInstanceOf(MemoryTextIndex); - }); - - it("builds a noop provider", () => { - expect(provideTextIndex({ provider: "noop" })).toBeInstanceOf(NoopTextIndex); - }); - - it("builds a typesense provider", () => { - const index = provideTextIndex({ - provider: "typesense", - typesense: { apiKey: "xyz" }, - }); - expect(index).toBeInstanceOf(TypesenseTextIndex); - }); - - it("rejects a typesense provider without its config", () => { - expect(() => provideTextIndex({ provider: "typesense" })).toThrow(/typesense/); - }); -}); - -// SRCH-2: the Elasticsearch full flow is live-only, but its 404-detection (so a delete-of-missing -// is a no-op instead of tripping the circuit breaker) is pure and unit-testable. -describe("isElasticNotFound", () => { - it("recognizes a 404 on statusCode or meta.statusCode", () => { - expect(isElasticNotFound({ statusCode: 404 })).toBe(true); - expect(isElasticNotFound({ meta: { statusCode: 404 } })).toBe(true); - }); - - it("is false for other statuses and non-errors", () => { - expect(isElasticNotFound({ statusCode: 500 })).toBe(false); - expect(isElasticNotFound({ meta: { statusCode: 503 } })).toBe(false); - expect(isElasticNotFound(new Error("boom"))).toBe(false); - expect(isElasticNotFound(undefined)).toBe(false); - expect(isElasticNotFound(null)).toBe(false); - }); -}); diff --git a/packages/search/src/text.ts b/packages/search/src/text.ts deleted file mode 100644 index 5581f0a..0000000 --- a/packages/search/src/text.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** A document to index for full-text search. */ -export interface TextDocument { - id: string; - text: string; - metadata?: Record; -} - -/** A single ranked result from a text search, ordered by descending {@link TextHit.score}. */ -export interface TextHit { - id: string; - score: number; - metadata?: Record; -} - -/** Options for {@link TextIndex.search}. */ -export interface TextSearchOptions { - /** - * Maximum number of hits to return. Defaults to `DEFAULT_SEARCH_LIMIT` (from `document-index`) - * when omitted — an explicit, uniform page size rather than each backend's silent default. - */ - limit?: number; -} - -/** - * The full-text search contract. A miss is an empty array rather than a sentinel error — the - * same idiomatic-TypeScript divergence the cache makes from Go's `(value, error)`. - */ -export interface TextIndex { - /** Indexes (or replaces) a document by id. */ - index(doc: TextDocument): Promise; - /** Returns hits matching the query, sorted by descending score and limited. */ - search(query: string, opts?: TextSearchOptions): Promise; - /** Removes a document by id. A no-op when the id is unknown. */ - delete(id: string): Promise; - /** Verifies the backing store is reachable. */ - ping(): Promise; -} - -/** - * A {@link TextIndex} that can index many documents in one backend round trip (Typesense - * `import`) rather than N sequential `index()` calls. Not every index supports it, so obtain one - * via {@link isBulkTextIndex}. - */ -export interface BulkTextIndex extends TextIndex { - /** Indexes (or replaces) every document in a single batched operation. */ - indexMany(docs: readonly TextDocument[]): Promise; -} - -/** Narrows a {@link TextIndex} to a {@link BulkTextIndex} when the provider supports batching. */ -export function isBulkTextIndex(index: TextIndex): index is BulkTextIndex { - return typeof (index as Partial).indexMany === "function"; -} diff --git a/packages/search/tsconfig.json b/packages/search/tsconfig.json deleted file mode 100644 index bdb9327..0000000 --- a/packages/search/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022"], - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/search/tsup.config.ts b/packages/search/tsup.config.ts deleted file mode 100644 index 8bf2394..0000000 --- a/packages/search/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { index: "src/index.ts" }, - platform: "node", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: true, -}); diff --git a/packages/secrets/CHANGELOG.md b/packages/secrets/CHANGELOG.md deleted file mode 100644 index f33d099..0000000 --- a/packages/secrets/CHANGELOG.md +++ /dev/null @@ -1,13 +0,0 @@ -# @primandproper/secrets - -## 0.0.2 - -### Patch Changes - -- a124406: Add a caching secret source (`CachingSecretSource`) with TTL config and real `ping()` probes. -- Updated dependencies [a124406] -- Updated dependencies [a124406] -- Updated dependencies [a124406] - - @primandproper/cache@0.1.0 - - @primandproper/errors@0.0.2 - - @primandproper/observability@0.1.0 diff --git a/packages/secrets/package.json b/packages/secrets/package.json deleted file mode 100644 index 469214a..0000000 --- a/packages/secrets/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@primandproper/secrets", - "version": "0.0.2", - "type": "module", - "description": "Server-only secrets: a SecretSource interface with swappable providers.", - "license": "AGPL-3.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/primandproper/platform-ts.git", - "directory": "packages/secrets" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "eslint .", - "clean": "rm -rf dist .turbo" - }, - "dependencies": { - "@aws-sdk/client-ssm": "3.699.0", - "@google-cloud/secret-manager": "6.2.0", - "@kubernetes/client-node": "1.4.0", - "@primandproper/cache": "workspace:*", - "@primandproper/errors": "workspace:*", - "@primandproper/observability": "workspace:*", - "zod": "3.25.76" - } -} diff --git a/packages/secrets/src/config.ts b/packages/secrets/src/config.ts deleted file mode 100644 index c4358e9..0000000 --- a/packages/secrets/src/config.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { z } from "zod"; - -/** Static-provider config: an inline map of secret keys to values. */ -export const StaticSecretsConfigSchema = z.object({ - values: z.record(z.string()).default({}), -}); - -export type StaticSecretsConfig = z.infer; - -/** Environment-provider config. An optional prefix is prepended to every lookup. */ -export const EnvSecretsConfigSchema = z.object({ - prefix: z.string().default(""), -}); - -export type EnvSecretsConfig = z.infer; - -/** GCP Secret Manager config. Mirrors Go's `gcp.Config` — `projectID` is required. */ -export const GCPSecretsConfigSchema = z.object({ - projectID: z.string().min(1), -}); - -export type GCPSecretsConfig = z.infer; - -/** AWS SSM Parameter Store config. Mirrors Go's `ssm.Config` — `region` is required. */ -export const SSMSecretsConfigSchema = z.object({ - region: z.string().min(1), - prefix: z.string().default(""), -}); - -export type SSMSecretsConfig = z.infer; - -/** Kubernetes secret source config. Mirrors Go's `kubectl.Config` — `namespace` is required. */ -export const KubectlSecretsConfigSchema = z.object({ - namespace: z.string().min(1), - kubeconfig: z.string().default(""), -}); - -export type KubectlSecretsConfig = z.infer; - -/** - * Caching config for the remote providers (gcp/ssm/kubectl). Short-TTL memoization plus in-flight - * de-duplication in front of the source, so repeated reads don't each round-trip and a provider - * blip keeps serving cached values. On by default with a short TTL; `enabled: false` opts out. - */ -export const SecretsCacheConfigSchema = z.object({ - enabled: z.boolean().default(true), - ttlMs: z.number().int().nonnegative().default(30_000), -}); - -export type SecretsCacheConfig = z.infer; - -/** Providers whose config block is mandatory — the analogue of Go's per-provider `When(Required)`. */ -const PROVIDERS_REQUIRING_CONFIG = ["static", "gcp", "ssm", "kubectl"] as const; - -/** - * Secrets config. Replaces the Go `env:`-tagged struct + ozzo `ValidateWithContext`. `env` - * (default) reads `process.env`; `static` serves an inline map; `noop` returns nothing; `gcp`, - * `ssm`, and `kubectl` back onto GCP Secret Manager, AWS SSM Parameter Store, and Kubernetes - * secrets respectively — all server-side, each requiring its own config block. - */ -export const SecretsConfigSchema = z - .object({ - provider: z.enum(["env", "static", "noop", "gcp", "ssm", "kubectl"]).default("env"), - env: EnvSecretsConfigSchema.optional(), - static: StaticSecretsConfigSchema.optional(), - gcp: GCPSecretsConfigSchema.optional(), - ssm: SSMSecretsConfigSchema.optional(), - kubectl: KubectlSecretsConfigSchema.optional(), - cache: SecretsCacheConfigSchema.default({}), - }) - .superRefine((cfg, ctx) => { - for (const provider of PROVIDERS_REQUIRING_CONFIG) { - if (cfg.provider === provider && cfg[provider] === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [provider], - message: `${provider} config is required when provider is '${provider}'`, - }); - } - } - }); - -export type SecretsConfig = z.infer; -export type SecretsConfigInput = z.input; diff --git a/packages/secrets/src/index.ts b/packages/secrets/src/index.ts deleted file mode 100644 index 2d4d6aa..0000000 --- a/packages/secrets/src/index.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { ObservabilityDeps } from "@primandproper/observability"; - -import { - SecretsConfigSchema, - type SecretsCacheConfig, - type SecretsConfigInput, -} from "./config.js"; -import { CachingSecretSource } from "./providers/caching.js"; -import { EnvSecretSource } from "./providers/env.js"; -import { GCPSecretSource } from "./providers/gcp.node.js"; -import { KubectlSecretSource } from "./providers/kubectl.node.js"; -import { NoopSecretSource } from "./providers/noop.js"; -import { SSMSecretSource } from "./providers/ssm.node.js"; -import { StaticSecretSource } from "./providers/static.js"; -import type { SecretSource } from "./secrets.js"; - -export * from "./secrets.js"; -export * from "./config.js"; -export { EnvSecretSource, type EnvSecretSourceOptions } from "./providers/env.js"; -export { StaticSecretSource } from "./providers/static.js"; -export { NoopSecretSource } from "./providers/noop.js"; -export { - GCPSecretSource, - type GCPSecretSourceOptions, - type GCPSecretAccessor, -} from "./providers/gcp.node.js"; -export { - SSMSecretSource, - type SSMSecretSourceOptions, - type SSMParameterAccessor, -} from "./providers/ssm.node.js"; -export { - KubectlSecretSource, - type KubectlSecretSourceOptions, - type K8sSecretReader, -} from "./providers/kubectl.node.js"; -export { - CachingSecretSource, - type CachingSecretSourceOptions, - DEFAULT_SECRET_TTL_MS, -} from "./providers/caching.js"; - -/** - * Narrows a per-provider config the schema's `superRefine` has already guaranteed present, so the - * type checker sees a defined value without a non-null assertion. - */ -function required(value: T | undefined, provider: string): T { - if (value === undefined) { - throw new Error(`${provider} config is required when provider is '${provider}'`); - } - return value; -} - -/** - * Validates config and returns the matching {@link SecretSource}. Mirrors the Go platform's - * `ProvideSecretSource`. Supports `env` (default), `static`, `noop`, `gcp`, `ssm`, and `kubectl`. - */ -export function provideSecrets( - config?: SecretsConfigInput, - deps?: ObservabilityDeps, -): SecretSource { - const cfg = SecretsConfigSchema.parse(config ?? {}); - switch (cfg.provider) { - case "env": - return new EnvSecretSource(cfg.env ? { prefix: cfg.env.prefix } : {}, deps); - case "static": - return new StaticSecretSource({ values: required(cfg.static, "static").values }); - case "noop": - return new NoopSecretSource(); - case "gcp": - return maybeCache( - new GCPSecretSource({ projectID: required(cfg.gcp, "gcp").projectID }, deps), - cfg.cache, - deps, - ); - case "ssm": { - const ssm = required(cfg.ssm, "ssm"); - return maybeCache( - new SSMSecretSource({ region: ssm.region, prefix: ssm.prefix }, deps), - cfg.cache, - deps, - ); - } - case "kubectl": { - const kubectl = required(cfg.kubectl, "kubectl"); - return maybeCache( - new KubectlSecretSource( - { namespace: kubectl.namespace, kubeconfig: kubectl.kubeconfig }, - deps, - ), - cfg.cache, - deps, - ); - } - } -} - -/** - * Wraps a remote source in a {@link CachingSecretSource} unless caching is disabled or given a - * zero TTL. Local sources (env/static/noop) are never wrapped — there is nothing to memoize. - */ -function maybeCache( - source: SecretSource, - cache: SecretsCacheConfig, - deps: ObservabilityDeps | undefined, -): SecretSource { - if (!cache.enabled || cache.ttlMs <= 0) { - return source; - } - return new CachingSecretSource(source, { ttlMs: cache.ttlMs }, deps); -} diff --git a/packages/secrets/src/providers/caching.ts b/packages/secrets/src/providers/caching.ts deleted file mode 100644 index ec77d4b..0000000 --- a/packages/secrets/src/providers/caching.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { type Cache, provideCache } from "@primandproper/cache"; -import type { ObservabilityDeps } from "@primandproper/observability"; - -import { getRequired, type SecretSource } from "../secrets.js"; - -/** Thirty seconds — the default memoization TTL, short enough that a rotated secret is picked up soon. */ -export const DEFAULT_SECRET_TTL_MS = 30_000; - -export interface CachingSecretSourceOptions { - /** Memoization TTL in milliseconds. `0` disables it entirely. Defaults to 30s. */ - ttlMs?: number; - /** Inject a cache backing store (composes with `@primandproper/cache`); defaults to an in-memory one. */ - cache?: Cache; -} - -/** - * A {@link SecretSource} decorator that adds short-TTL memoization and in-flight de-duplication in - * front of any inner source. It composes with `@primandproper/cache` (an {@link InMemoryCache} by - * default) so repeated reads of the same key don't each cost a remote round trip, and concurrent - * reads of the same key collapse to a single upstream call — which also blunts a Secret Manager - * blip, since a still-cached value keeps serving. Only positive results are cached: a miss is - * re-checked every time so a newly-created secret is seen promptly. - */ -export class CachingSecretSource implements SecretSource { - readonly #inner: SecretSource; - readonly #cache: Cache; - readonly #inflight = new Map>(); - - constructor( - inner: SecretSource, - options: CachingSecretSourceOptions = {}, - deps: ObservabilityDeps = {}, - ) { - this.#inner = inner; - this.#cache = - options.cache ?? - provideCache( - { provider: "memory", expiryMs: options.ttlMs ?? DEFAULT_SECRET_TTL_MS }, - deps, - ); - } - - get(key: string): Promise { - // Register the in-flight promise synchronously so concurrent callers share one upstream call. - const pending = this.#inflight.get(key); - if (pending !== undefined) { - return pending; - } - const tracked = this.#resolve(key).finally(() => { - this.#inflight.delete(key); - }); - this.#inflight.set(key, tracked); - return tracked; - } - - async #resolve(key: string): Promise { - const cached = await this.#cache.get(key); - if (cached !== undefined) { - return cached; - } - const value = await this.#inner.get(key); - if (value !== undefined) { - await this.#cache.set(key, value); - } - return value; - } - - getRequired(key: string): Promise { - return getRequired(this, key); - } - - ping(): Promise { - return this.#inner.ping(); - } - - /** Closes both the cache and the wrapped source. */ - async close(): Promise { - await this.#cache.close(); - await this.#inner.close(); - } -} diff --git a/packages/secrets/src/providers/env.ts b/packages/secrets/src/providers/env.ts deleted file mode 100644 index 365ec45..0000000 --- a/packages/secrets/src/providers/env.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { getRequired, type SecretSource } from "../secrets.js"; - -import { SECRET_KEY, secretInstruments, type SecretInstruments } from "./support.js"; - -const sourceName = "env_secret_source"; - -export interface EnvSecretSourceOptions { - /** Prepended to every key before reading from the environment. */ - prefix?: string; - /** The environment to read from. Defaults to `process.env`; injectable for tests. */ - env?: Record; -} - -/** Reads secrets from process environment variables. The default server-side provider. */ -export class EnvSecretSource implements SecretSource { - readonly #prefix: string; - readonly #env: Record; - readonly #observer: Observer; - readonly #instruments: SecretInstruments; - - constructor(options: EnvSecretSourceOptions = {}, deps: ObservabilityDeps = {}) { - this.#prefix = options.prefix ?? ""; - this.#env = options.env ?? process.env; - this.#observer = deps.observer ?? makeObserver(sourceName, deps); - this.#instruments = secretInstruments(deps, sourceName); - } - - get(key: string): Promise { - return this.#observer.run("get_secret", (op) => { - const start = performance.now(); - try { - const name = this.#prefix + key; - // NOTE: only the lookup key is observed, never the secret's value. - op.set(SECRET_KEY, name); - this.#instruments.lookups.add(1); - - const value = this.#env[name]; - if (value === undefined) { - op.logger().debug("secret not set"); - } - return value; - } finally { - this.#instruments.latency.record(performance.now() - start); - } - }); - } - - getRequired(key: string): Promise { - return getRequired(this, key); - } - - ping(): Promise { - return Promise.resolve(); - } - - close(): Promise { - this.#observer.logger().debug("closing env secret source"); - return Promise.resolve(); - } -} diff --git a/packages/secrets/src/providers/gcp.node.ts b/packages/secrets/src/providers/gcp.node.ts deleted file mode 100644 index a4e8bf3..0000000 --- a/packages/secrets/src/providers/gcp.node.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { getRequired, type SecretSource } from "../secrets.js"; - -import { - NAME_KEY, - PROJECT_ID_KEY, - secretInstruments, - type SecretInstruments, -} from "./support.js"; - -const sourceName = "gcp_secret_source"; -const SECRET_VERSION_LATEST = "latest"; -const PROJECTS_PREFIX = "projects/"; - -/** Sentinel name probed by {@link GCPSecretSource.ping}; a NOT_FOUND still proves reachability. */ -const PING_PROBE_NAME = "__platform_ping_probe__"; - -/** gRPC status code for NOT_FOUND, the shape the Secret Manager client throws on a missing version. */ -const GRPC_NOT_FOUND = 5; - -/** - * Minimal GCP Secret Manager seam — the analogue of Go's `SecretVersionAccessor`. `access` - * returns `undefined` for a version that does not exist so a miss stays a miss; auth/network - * failures propagate. Inject a fake in tests; the real adapter wraps the SDK client. - */ -export interface GCPSecretAccessor { - /** Returns the payload for a fully-qualified resource name, or `undefined` if it is absent. */ - access(resourceName: string): Promise; - close(): Promise; -} - -export interface GCPSecretSourceOptions { - /** The GCP project secrets are resolved against. */ - projectID: string; - /** Inject a client for tests; defaults to a real Secret Manager client (Application Default Credentials). */ - client?: GCPSecretAccessor; -} - -function isNotFound(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - (err as { code?: unknown }).code === GRPC_NOT_FOUND - ); -} - -/** Wraps the real Secret Manager client, translating NOT_FOUND into a miss. */ -class RealGCPAccessor implements GCPSecretAccessor { - readonly #client = new SecretManagerServiceClient(); - - async access(resourceName: string): Promise { - try { - const [response] = await this.#client.accessSecretVersion({ name: resourceName }); - const data = response.payload?.data; - if (data === undefined || data === null) { - return undefined; - } - return typeof data === "string" ? data : Buffer.from(data).toString("utf8"); - } catch (err) { - if (isNotFound(err)) { - return undefined; - } - throw err; - } - } - - close(): Promise { - return this.#client.close(); - } -} - -/** A {@link SecretSource} backed by GCP Secret Manager. Faithful to Go's `gcp` secret source. */ -export class GCPSecretSource implements SecretSource { - readonly #client: GCPSecretAccessor; - readonly #projectID: string; - readonly #observer: Observer; - readonly #instruments: SecretInstruments; - - constructor(options: GCPSecretSourceOptions, deps: ObservabilityDeps = {}) { - this.#projectID = options.projectID; - this.#client = options.client ?? new RealGCPAccessor(); - this.#observer = deps.observer ?? makeObserver(sourceName, deps); - this.#instruments = secretInstruments(deps, sourceName); - } - - get(key: string): Promise { - return this.#observer.run("get_secret", async (op) => { - const start = performance.now(); - op.set(NAME_KEY, key).set(PROJECT_ID_KEY, this.#projectID); - try { - const value = await this.#client.access(this.#resolveName(key)); - this.#instruments.lookups.add(1); - return value; - } catch (err) { - this.#instruments.errors.add(1); - throw op.error(err, `accessing secret ${key}`); - } finally { - this.#instruments.latency.record(performance.now() - start); - } - }); - } - - getRequired(key: string): Promise { - return getRequired(this, key); - } - - /** - * Probes reachability + credentials by accessing a sentinel secret version. Secret Manager has - * no dedicated health endpoint, but a NOT_FOUND resolves to `undefined` (proving the endpoint is - * reachable and ADC worked) while an auth/network failure throws — so a broken config fails here - * rather than silently on the first real `get`. - */ - ping(): Promise { - return this.#observer.run("ping", async (op) => { - op.set(PROJECT_ID_KEY, this.#projectID); - try { - await this.#client.access(this.#resolveName(PING_PROBE_NAME)); - } catch (err) { - throw op.error(err, "pinging gcp secret manager"); - } - }); - } - - close(): Promise { - return this.#client.close(); - } - - #resolveName(name: string): string { - if (name.startsWith(PROJECTS_PREFIX)) { - return name; - } - return `projects/${this.#projectID}/secrets/${name}/versions/${SECRET_VERSION_LATEST}`; - } -} diff --git a/packages/secrets/src/providers/kubectl.node.ts b/packages/secrets/src/providers/kubectl.node.ts deleted file mode 100644 index 0efcd44..0000000 --- a/packages/secrets/src/providers/kubectl.node.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { CoreV1Api, KubeConfig } from "@kubernetes/client-node"; -import { PlatformError } from "@primandproper/errors"; -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { getRequired, type SecretSource } from "../secrets.js"; - -import { - SECRET_DATA_KEY, - SECRET_NAME_KEY, - secretInstruments, - type SecretInstruments, -} from "./support.js"; - -const sourceName = "kubectl_secret_source"; - -/** Sentinel secret name probed by {@link KubectlSecretSource.ping}; a 404 still proves reachability. */ -const PING_PROBE_NAME = "__platform-ping-probe__"; - -/** - * Minimal Kubernetes secrets seam — the analogue of Go's `SecretGetter`. `read` returns the - * secret's decoded (base64 → utf8) data map, or `undefined` if the secret does not exist so a - * miss stays a miss; other failures propagate. Inject a fake in tests; the real adapter wraps - * the SDK client and namespace. - */ -export interface K8sSecretReader { - read(name: string): Promise | undefined>; -} - -export interface KubectlSecretSourceOptions { - /** The namespace secrets are read from. */ - namespace: string; - /** Path to a kubeconfig; empty uses in-cluster config. Mirrors Go's `kubectl.Config.Kubeconfig`. */ - kubeconfig?: string; - /** Inject a reader for tests; defaults to a real client built from the kubeconfig or in-cluster config. */ - client?: K8sSecretReader; -} - -function isNotFound(err: unknown): boolean { - if (typeof err !== "object" || err === null) { - return false; - } - const status = err as { code?: unknown; statusCode?: unknown }; - return status.code === 404 || status.statusCode === 404; -} - -/** Wraps the real Kubernetes client, translating a 404 into a miss and decoding base64 data. */ -class RealK8sReader implements K8sSecretReader { - readonly #api: CoreV1Api; - readonly #namespace: string; - - constructor(namespace: string, kubeconfig: string) { - const kc = new KubeConfig(); - if (kubeconfig === "") { - kc.loadFromCluster(); - } else { - kc.loadFromFile(kubeconfig); - } - this.#api = kc.makeApiClient(CoreV1Api); - this.#namespace = namespace; - } - - async read(name: string): Promise | undefined> { - try { - const secret = await this.#api.readNamespacedSecret({ - name, - namespace: this.#namespace, - }); - const data = secret.data ?? {}; - const decoded: Record = {}; - for (const [key, value] of Object.entries(data)) { - decoded[key] = Buffer.from(value, "base64").toString("utf8"); - } - return decoded; - } catch (err) { - if (isNotFound(err)) { - return undefined; - } - throw err; - } - } -} - -/** - * Splits a lookup of the form `secret-name/key` into its parts. An input without a `/` is a - * caller error, not a miss — it throws, matching Go's `resolveName`. - */ -function resolveName(input: string): { secretName: string; key: string } { - const slash = input.indexOf("/"); - if (slash === -1) { - throw new PlatformError( - "secrets/invalid-key", - `invalid secret name "${input}": expected format "secret-name/key"`, - ); - } - return { secretName: input.slice(0, slash), key: input.slice(slash + 1) }; -} - -/** A {@link SecretSource} backed by Kubernetes secrets. Faithful to Go's `kubectl` secret source. */ -export class KubectlSecretSource implements SecretSource { - readonly #client: K8sSecretReader; - readonly #observer: Observer; - readonly #instruments: SecretInstruments; - - constructor(options: KubectlSecretSourceOptions, deps: ObservabilityDeps = {}) { - this.#client = - options.client ?? new RealK8sReader(options.namespace, options.kubeconfig ?? ""); - this.#observer = deps.observer ?? makeObserver(sourceName, deps); - this.#instruments = secretInstruments(deps, sourceName); - } - - get(key: string): Promise { - return this.#observer.run("get_secret", async (op) => { - const start = performance.now(); - try { - const { secretName, key: dataKey } = resolveName(key); - op.set(SECRET_NAME_KEY, secretName).set(SECRET_DATA_KEY, dataKey); - - const data = await this.#client.read(secretName); - this.#instruments.lookups.add(1); - // A missing secret or a missing key within it is a miss, not an error. - return data?.[dataKey]; - } catch (err) { - this.#instruments.errors.add(1); - throw op.error(err, `getting kubernetes secret ${key}`); - } finally { - this.#instruments.latency.record(performance.now() - start); - } - }); - } - - getRequired(key: string): Promise { - return getRequired(this, key); - } - - /** - * Probes reachability + credentials by reading a sentinel secret. A 404 resolves to `undefined` - * (proving the API server is reachable and the kubeconfig/in-cluster credentials work) while an - * auth/network failure throws — so a broken config fails here rather than on the first real `get`. - */ - ping(): Promise { - return this.#observer.run("ping", async (op) => { - op.set(SECRET_NAME_KEY, PING_PROBE_NAME); - try { - await this.#client.read(PING_PROBE_NAME); - } catch (err) { - throw op.error(err, "pinging kubernetes api"); - } - }); - } - - close(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/secrets/src/providers/noop.ts b/packages/secrets/src/providers/noop.ts deleted file mode 100644 index 3dfe445..0000000 --- a/packages/secrets/src/providers/noop.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { getRequired, type SecretSource } from "../secrets.js"; - -/** A {@link SecretSource} that knows no secrets; every lookup is a miss. */ -export class NoopSecretSource implements SecretSource { - get(): Promise { - return Promise.resolve(undefined); - } - - getRequired(key: string): Promise { - return getRequired(this, key); - } - - ping(): Promise { - return Promise.resolve(); - } - - close(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/secrets/src/providers/ssm.node.ts b/packages/secrets/src/providers/ssm.node.ts deleted file mode 100644 index 0ed2aa5..0000000 --- a/packages/secrets/src/providers/ssm.node.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { - GetParameterCommand, - SSMClient, - type SSMClientConfig, -} from "@aws-sdk/client-ssm"; -import { - makeObserver, - type ObservabilityDeps, - type Observer, -} from "@primandproper/observability"; - -import { getRequired, type SecretSource } from "../secrets.js"; - -import { NAME_KEY, secretInstruments, type SecretInstruments } from "./support.js"; - -const sourceName = "ssm_secret_source"; - -/** Sentinel name probed by {@link SSMSecretSource.ping}; a ParameterNotFound still proves reachability. */ -const PING_PROBE_NAME = "__platform_ping_probe__"; - -/** - * Minimal SSM Parameter Store seam — the analogue of Go's `GetParameterAPI`. `get` returns - * `undefined` for a parameter that does not exist so a miss stays a miss; other failures - * propagate. Inject a fake in tests; the real adapter wraps the SDK client. - */ -export interface SSMParameterAccessor { - get(name: string): Promise; - /** Releases the underlying client, if any. */ - close?(): void; -} - -export interface SSMSecretSourceOptions { - region?: string | undefined; - /** Prepended to every non-absolute parameter name, mirroring Go's `ssm.Config.Prefix`. */ - prefix?: string | undefined; - /** Override the SSM endpoint — e.g. a LocalStack URL for tests. */ - endpoint?: string | undefined; - credentials?: - | { accessKeyId: string; secretAccessKey: string; sessionToken?: string | undefined } - | undefined; - /** Inject a client for tests; defaults to a real SSM client (default credential chain). */ - client?: SSMParameterAccessor; -} - -function isNotFound(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - (err as { name?: unknown }).name === "ParameterNotFound" - ); -} - -/** Wraps the real SSM client, translating ParameterNotFound into a miss. */ -class RealSSMAccessor implements SSMParameterAccessor { - readonly #client: SSMClient; - - constructor(options: SSMSecretSourceOptions) { - const config: SSMClientConfig = {}; - if (options.region !== undefined) { - config.region = options.region; - } - if (options.endpoint !== undefined) { - config.endpoint = options.endpoint; - } - if (options.credentials !== undefined) { - const { accessKeyId, secretAccessKey, sessionToken } = options.credentials; - config.credentials = { - accessKeyId, - secretAccessKey, - ...(sessionToken === undefined ? {} : { sessionToken }), - }; - } - this.#client = new SSMClient(config); - } - - async get(name: string): Promise { - try { - const output = await this.#client.send( - new GetParameterCommand({ Name: name, WithDecryption: true }), - ); - return output.Parameter?.Value ?? undefined; - } catch (err) { - if (isNotFound(err)) { - return undefined; - } - throw err; - } - } - - close(): void { - this.#client.destroy(); - } -} - -/** A {@link SecretSource} backed by AWS SSM Parameter Store. Faithful to Go's `ssm` secret source. */ -export class SSMSecretSource implements SecretSource { - readonly #client: SSMParameterAccessor; - readonly #prefix: string; - readonly #observer: Observer; - readonly #instruments: SecretInstruments; - - constructor(options: SSMSecretSourceOptions = {}, deps: ObservabilityDeps = {}) { - this.#prefix = options.prefix ?? ""; - this.#client = options.client ?? new RealSSMAccessor(options); - this.#observer = deps.observer ?? makeObserver(sourceName, deps); - this.#instruments = secretInstruments(deps, sourceName); - } - - get(key: string): Promise { - return this.#observer.run("get_secret", async (op) => { - const start = performance.now(); - const paramName = this.#resolveName(key); - op.set(NAME_KEY, paramName); - try { - const value = await this.#client.get(paramName); - this.#instruments.lookups.add(1); - return value; - } catch (err) { - this.#instruments.errors.add(1); - throw op.error(err, `getting parameter ${key}`); - } finally { - this.#instruments.latency.record(performance.now() - start); - } - }); - } - - getRequired(key: string): Promise { - return getRequired(this, key); - } - - /** - * Probes reachability + credentials by getting a sentinel parameter. A ParameterNotFound - * resolves to `undefined` (proving the endpoint is reachable and credentials work) while an - * auth/network failure throws — so a broken config fails here rather than on the first real `get`. - */ - ping(): Promise { - return this.#observer.run("ping", async (op) => { - const paramName = this.#resolveName(PING_PROBE_NAME); - op.set(NAME_KEY, paramName); - try { - await this.#client.get(paramName); - } catch (err) { - throw op.error(err, "pinging ssm parameter store"); - } - }); - } - - close(): Promise { - this.#client.close?.(); - return Promise.resolve(); - } - - #resolveName(name: string): string { - if (name.startsWith("/")) { - return name; - } - return this.#prefix === "" ? name : this.#prefix + name; - } -} diff --git a/packages/secrets/src/providers/static.ts b/packages/secrets/src/providers/static.ts deleted file mode 100644 index 32fca55..0000000 --- a/packages/secrets/src/providers/static.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { getRequired, type SecretSource } from "../secrets.js"; - -export interface StaticSecretSourceOptions { - values?: Record; -} - -/** - * Serves secrets from an inline map. Useful for tests and for wiring secrets that already - * live in validated config. Universal logic, but the package is server-only by modality. - */ -export class StaticSecretSource implements SecretSource { - readonly #values: Record; - - constructor(options: StaticSecretSourceOptions = {}) { - this.#values = { ...options.values }; - } - - get(key: string): Promise { - return Promise.resolve( - Object.prototype.hasOwnProperty.call(this.#values, key) - ? this.#values[key] - : undefined, - ); - } - - getRequired(key: string): Promise { - return getRequired(this, key); - } - - ping(): Promise { - return Promise.resolve(); - } - - close(): Promise { - return Promise.resolve(); - } -} diff --git a/packages/secrets/src/providers/support.ts b/packages/secrets/src/providers/support.ts deleted file mode 100644 index f9b22e5..0000000 --- a/packages/secrets/src/providers/support.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - makeMetrics, - type Metrics, - type ObservabilityDeps, -} from "@primandproper/observability"; - -/** The meter every provider registers its instruments under. */ -export const o11yName = "secrets"; - -type Counter = ReturnType; -type Histogram = ReturnType; - -/** - * Span/log attribute keys, mirroring the literals each Go secret source observes. Only lookup - * identifiers are ever attached — a secret's value is never observed. - */ -export const NAME_KEY = "name"; // keys.NameKey — gcp/ssm -export const SECRET_KEY = "secret_key"; // env -export const PROJECT_ID_KEY = "project.id"; // gcp -export const SECRET_NAME_KEY = "secret.name"; // kubectl -export const SECRET_DATA_KEY = "secret.key"; // kubectl - -/** - * The per-source instruments every Go secret source mints in its constructor: - * `{name}_lookups`, `{name}_errors`, and the `{name}_latency_ms` histogram. - */ -export interface SecretInstruments { - lookups: Counter; - errors: Counter; - latency: Histogram; -} - -/** Builds the lookups/errors/latency instruments for a source named `name`. */ -export function secretInstruments( - deps: ObservabilityDeps | undefined, - name: string, -): SecretInstruments { - const metrics = makeMetrics(o11yName, deps?.metrics); - return { - lookups: metrics.counter(`${name}_lookups`), - errors: metrics.counter(`${name}_errors`), - latency: metrics.histogram(`${name}_latency_ms`, { unit: "ms" }), - }; -} diff --git a/packages/secrets/src/secrets.test.ts b/packages/secrets/src/secrets.test.ts deleted file mode 100644 index 2438081..0000000 --- a/packages/secrets/src/secrets.test.ts +++ /dev/null @@ -1,364 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { EnvSecretSource } from "./providers/env.js"; -import { GCPSecretSource, type GCPSecretAccessor } from "./providers/gcp.node.js"; -import { KubectlSecretSource, type K8sSecretReader } from "./providers/kubectl.node.js"; -import { NoopSecretSource } from "./providers/noop.js"; -import { SSMSecretSource, type SSMParameterAccessor } from "./providers/ssm.node.js"; -import { StaticSecretSource } from "./providers/static.js"; - -import { - CachingSecretSource, - getRequired, - provideSecrets, - MissingSecretError, - type SecretSource, -} from "./index.js"; - -/** A GCP seam fake keyed by the bare secret id extracted from a resolved resource name. */ -function gcpFake(values: Record): GCPSecretAccessor { - return { - access(resourceName) { - const match = /secrets\/([^/]+)\//.exec(resourceName); - const id = match?.[1] ?? resourceName; - return Promise.resolve(values[id]); - }, - close: () => Promise.resolve(), - }; -} - -/** An SSM seam fake keyed by resolved parameter name. */ -function ssmFake(values: Record): SSMParameterAccessor { - return { get: (name) => Promise.resolve(values[name]) }; -} - -/** A Kubernetes seam fake keyed by secret name, returning an already-decoded data map. */ -function k8sFake(secrets: Record>): K8sSecretReader { - return { read: (name) => Promise.resolve(secrets[name]) }; -} - -/** - * Provider-agnostic conformance suite. Running the same assertions against multiple providers - * proves the `SecretSource` interface is implementation-independent. Every provider whose keys - * are bare (i.e. not the `secret/key` shape kubectl demands) participates. - */ -function conformance( - name: string, - make: () => SecretSource, - opts: { readonly knowsSecret: boolean }, -): void { - describe(name, () => { - it("returns undefined for an unknown secret", async () => { - expect(await make().get("unknown")).toBeUndefined(); - }); - - it("reads a known secret", async () => { - expect(await make().get("known")).toBe(opts.knowsSecret ? "value" : undefined); - }); - - it("throws MissingSecretError from getRequired on a miss", async () => { - await expect(make().getRequired("unknown")).rejects.toBeInstanceOf( - MissingSecretError, - ); - }); - - it("pings without throwing", async () => { - await expect(make().ping()).resolves.toBeUndefined(); - }); - - it("closes without throwing", async () => { - await expect(make().close()).resolves.toBeUndefined(); - }); - }); -} - -conformance("EnvSecretSource", () => new EnvSecretSource({ env: { known: "value" } }), { - knowsSecret: true, -}); -conformance( - "StaticSecretSource", - () => new StaticSecretSource({ values: { known: "value" } }), - { knowsSecret: true }, -); -conformance("NoopSecretSource", () => new NoopSecretSource(), { knowsSecret: false }); -conformance( - "GCPSecretSource", - () => new GCPSecretSource({ projectID: "p", client: gcpFake({ known: "value" }) }), - { knowsSecret: true }, -); -conformance( - "SSMSecretSource", - () => new SSMSecretSource({ client: ssmFake({ known: "value" }) }), - { knowsSecret: true }, -); - -describe("EnvSecretSource prefix", () => { - it("prepends the prefix before reading", async () => { - const source = new EnvSecretSource({ prefix: "APP_", env: { APP_TOKEN: "t" } }); - expect(await source.get("TOKEN")).toBe("t"); - }); -}); - -describe("GCPSecretSource", () => { - it("resolves a bare key into the latest version resource name", async () => { - let seen = ""; - const source = new GCPSecretSource({ - projectID: "proj", - client: { - access(resourceName) { - seen = resourceName; - return Promise.resolve("v"); - }, - close: () => Promise.resolve(), - }, - }); - await source.get("db-password"); - expect(seen).toBe("projects/proj/secrets/db-password/versions/latest"); - }); - - it("passes a fully-qualified resource name through untouched", async () => { - let seen = ""; - const source = new GCPSecretSource({ - projectID: "proj", - client: { - access(resourceName) { - seen = resourceName; - return Promise.resolve("v"); - }, - close: () => Promise.resolve(), - }, - }); - await source.get("projects/other/secrets/x/versions/3"); - expect(seen).toBe("projects/other/secrets/x/versions/3"); - }); - - it("propagates a non-not-found error from the client", async () => { - const source = new GCPSecretSource({ - projectID: "p", - client: { - access: () => Promise.reject(new Error("permission denied")), - close: () => Promise.resolve(), - }, - }); - await expect(source.get("x")).rejects.toThrow("permission denied"); - }); - - it("closes the underlying client", async () => { - let closed = false; - const source = new GCPSecretSource({ - projectID: "p", - client: { - access: () => Promise.resolve(undefined), - close: () => { - closed = true; - return Promise.resolve(); - }, - }, - }); - await source.close(); - expect(closed).toBe(true); - }); - - it("ping rejects when the backend is unreachable/unauthorized", async () => { - const source = new GCPSecretSource({ - projectID: "p", - client: { - access: () => Promise.reject(new Error("permission denied")), - close: () => Promise.resolve(), - }, - }); - await expect(source.ping()).rejects.toThrow("permission denied"); - }); -}); - -describe("SSMSecretSource", () => { - it("passes an absolute name through, otherwise prepends the prefix", async () => { - const seen: string[] = []; - const client: SSMParameterAccessor = { - get(name) { - seen.push(name); - return Promise.resolve("v"); - }, - }; - const source = new SSMSecretSource({ prefix: "/app/", client }); - await source.get("token"); - await source.get("/absolute/token"); - expect(seen).toEqual(["/app/token", "/absolute/token"]); - }); - - it("propagates a non-not-found error from the client", async () => { - const source = new SSMSecretSource({ - client: { get: () => Promise.reject(new Error("throttled")) }, - }); - await expect(source.get("x")).rejects.toThrow("throttled"); - }); - - it("ping rejects when the backend is unreachable/unauthorized", async () => { - const source = new SSMSecretSource({ - client: { get: () => Promise.reject(new Error("throttled")) }, - }); - await expect(source.ping()).rejects.toThrow("throttled"); - }); -}); - -describe("KubectlSecretSource", () => { - const make = (): KubectlSecretSource => - new KubectlSecretSource({ - namespace: "default", - client: k8sFake({ "app-secrets": { password: "hunter2" } }), - }); - - it("reads a key out of a named secret", async () => { - expect(await make().get("app-secrets/password")).toBe("hunter2"); - }); - - it("returns undefined for a missing key in an existing secret", async () => { - expect(await make().get("app-secrets/missing")).toBeUndefined(); - }); - - it("returns undefined for a missing secret", async () => { - expect(await make().get("nope/password")).toBeUndefined(); - }); - - it("getRequired throws MissingSecretError on a miss", async () => { - await expect(make().getRequired("app-secrets/missing")).rejects.toBeInstanceOf( - MissingSecretError, - ); - }); - - it("rejects a name without the secret/key separator", async () => { - await expect(make().get("no-separator")).rejects.toThrow(/expected format/); - }); - - it("pings and closes without throwing", async () => { - await expect(make().ping()).resolves.toBeUndefined(); - await expect(make().close()).resolves.toBeUndefined(); - }); - - it("ping rejects when the api server is unreachable/unauthorized", async () => { - const source = new KubectlSecretSource({ - namespace: "default", - client: { read: () => Promise.reject(new Error("connection refused")) }, - }); - await expect(source.ping()).rejects.toThrow("connection refused"); - }); -}); - -describe("provideSecrets", () => { - it("defaults to the env provider", () => { - expect(provideSecrets(undefined, {})).toBeInstanceOf(EnvSecretSource); - }); - - it("serves a static map", async () => { - const source = provideSecrets({ provider: "static", static: { values: { a: "1" } } }); - expect(await source.get("a")).toBe("1"); - }); - - it("wraps a gcp source in a caching decorator by default", () => { - const source = provideSecrets({ provider: "gcp", gcp: { projectID: "p" } }); - expect(source).toBeInstanceOf(CachingSecretSource); - }); - - it("builds a bare gcp source when caching is disabled", () => { - const source = provideSecrets({ - provider: "gcp", - gcp: { projectID: "p" }, - cache: { enabled: false }, - }); - expect(source).toBeInstanceOf(GCPSecretSource); - }); - - it("wraps an ssm source in a caching decorator by default", () => { - const source = provideSecrets({ provider: "ssm", ssm: { region: "us-east-1" } }); - expect(source).toBeInstanceOf(CachingSecretSource); - }); - - it("rejects a static provider without config", () => { - expect(() => provideSecrets({ provider: "static" })).toThrow(); - }); - - it("rejects a gcp provider without config", () => { - expect(() => provideSecrets({ provider: "gcp" })).toThrow(); - }); - - it("rejects an ssm provider missing its required region", () => { - // @ts-expect-error region is required by the schema - expect(() => provideSecrets({ provider: "ssm", ssm: {} })).toThrow(); - }); - - it("rejects a kubectl provider without config", () => { - expect(() => provideSecrets({ provider: "kubectl" })).toThrow(); - }); -}); - -/** A SecretSource that counts calls and can be made slow, for exercising the caching decorator. */ -class CountingSource implements SecretSource { - calls = 0; - #values: Record; - #delayMs: number; - constructor(values: Record, delayMs = 0) { - this.#values = values; - this.#delayMs = delayMs; - } - async get(key: string): Promise { - this.calls += 1; - if (this.#delayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, this.#delayMs)); - } - return this.#values[key]; - } - getRequired(key: string): Promise { - return getRequired(this, key); - } - ping(): Promise { - return Promise.resolve(); - } - close(): Promise { - return Promise.resolve(); - } -} - -describe("CachingSecretSource (PERF-2)", () => { - it("memoizes a positive lookup within the TTL", async () => { - const inner = new CountingSource({ token: "abc" }); - const cache = new CachingSecretSource(inner, { ttlMs: 10_000 }); - - expect(await cache.get("token")).toBe("abc"); - expect(await cache.get("token")).toBe("abc"); - expect(inner.calls).toBe(1); // second read served from cache - }); - - it("does not cache a miss (re-checks every time)", async () => { - const inner = new CountingSource({}); - const cache = new CachingSecretSource(inner, { ttlMs: 10_000 }); - - expect(await cache.get("absent")).toBeUndefined(); - expect(await cache.get("absent")).toBeUndefined(); - expect(inner.calls).toBe(2); // a newly-created secret must be seen promptly - }); - - it("de-duplicates concurrent lookups of the same key into one upstream call", async () => { - const inner = new CountingSource({ token: "abc" }, 20); - const cache = new CachingSecretSource(inner, { ttlMs: 10_000 }); - - const [a, b, c] = await Promise.all([ - cache.get("token"), - cache.get("token"), - cache.get("token"), - ]); - - expect([a, b, c]).toStrictEqual(["abc", "abc", "abc"]); - expect(inner.calls).toBe(1); // three concurrent reads collapsed to one - }); - - it("closes the wrapped source", async () => { - const inner = new CountingSource({}); - let closed = false; - inner.close = () => { - closed = true; - return Promise.resolve(); - }; - await new CachingSecretSource(inner).close(); - expect(closed).toBe(true); - }); -}); diff --git a/packages/secrets/src/secrets.ts b/packages/secrets/src/secrets.ts deleted file mode 100644 index ff7f7f3..0000000 --- a/packages/secrets/src/secrets.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { PlatformError } from "@primandproper/errors"; - -/** - * The secrets contract. A missing secret is `undefined` rather than a sentinel error — the - * same idiomatic-TypeScript divergence the cache makes from Go's `(value, error)`. Use - * {@link getRequired} at startup for secrets whose absence should be fatal. - */ -export interface SecretSource { - /** Returns the secret's value, or `undefined` when it is not set. */ - get(key: string): Promise; - /** Like {@link get}, but throws {@link MissingSecretError} when the secret is absent. */ - getRequired(key: string): Promise; - /** Verifies the backing store is reachable. */ - ping(): Promise; - /** Releases any resources the source holds (SDK clients, connections). Mirrors Go's `Close`. */ - close(): Promise; -} - -/** Thrown by {@link SecretSource.getRequired} when a required secret is not set. */ -export class MissingSecretError extends PlatformError { - constructor(key: string) { - super("secrets/missing", `required secret is not set: ${key}`); - this.name = "MissingSecretError"; - } -} - -/** - * Default {@link SecretSource.getRequired} built on {@link SecretSource.get}, so providers - * only implement the optional read and share identical required-secret semantics. - */ -export async function getRequired( - source: Pick, - key: string, -): Promise { - const value = await source.get(key); - if (value === undefined) { - throw new MissingSecretError(key); - } - return value; -} diff --git a/packages/secrets/tsconfig.json b/packages/secrets/tsconfig.json deleted file mode 100644 index bdb9327..0000000 --- a/packages/secrets/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022"], - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/secrets/tsup.config.ts b/packages/secrets/tsup.config.ts deleted file mode 100644 index 8bf2394..0000000 --- a/packages/secrets/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { index: "src/index.ts" }, - platform: "node", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: true, -}); diff --git a/packages/uploads/CHANGELOG.md b/packages/uploads/CHANGELOG.md deleted file mode 100644 index 529e39f..0000000 --- a/packages/uploads/CHANGELOG.md +++ /dev/null @@ -1,16 +0,0 @@ -# @primandproper/uploads - -## 0.1.0 - -### Minor Changes - -- a124406: Replace the `ErrCircuitBroken` sentinel export with `newCircuitBrokenError()` and `CIRCUIT_BROKEN_CODE`. Breaking for code importing or comparing against `ErrCircuitBroken`. Adds `maxSizeBytes` config and `newFileTooLargeError()`. - -### Patch Changes - -- Updated dependencies [a124406] -- Updated dependencies [a124406] -- Updated dependencies [a124406] - - @primandproper/circuitbreaking@0.0.2 - - @primandproper/errors@0.0.2 - - @primandproper/observability@0.1.0 diff --git a/packages/uploads/package.json b/packages/uploads/package.json deleted file mode 100644 index a4316fe..0000000 --- a/packages/uploads/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@primandproper/uploads", - "version": "0.1.0", - "type": "module", - "description": "Server-only blob uploads: a BlobStore interface with swappable providers.", - "license": "AGPL-3.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/primandproper/platform-ts.git", - "directory": "packages/uploads" - }, - "publishConfig": { - "access": "public" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "lint": "eslint .", - "clean": "rm -rf dist .turbo" - }, - "dependencies": { - "@aws-sdk/client-s3": "3.700.0", - "@aws-sdk/lib-storage": "3.700.0", - "@aws-sdk/s3-request-presigner": "3.700.0", - "@google-cloud/storage": "7.21.0", - "@primandproper/circuitbreaking": "workspace:*", - "@primandproper/errors": "workspace:*", - "@primandproper/observability": "workspace:*", - "zod": "3.25.76" - } -} diff --git a/packages/uploads/src/bucket.ts b/packages/uploads/src/bucket.ts deleted file mode 100644 index 0967ed4..0000000 --- a/packages/uploads/src/bucket.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { PlatformError } from "@primandproper/errors"; - -import type { Attributes, ObjectInfo, SignedURLOptions } from "./capabilities.js"; -import type { BlobBody } from "./stream.js"; -import type { SaveOptions } from "./uploads.js"; - -/** - * The internal storage seam every provider implements — the analogue of the `*blob.Bucket` that - * platform-go's `Uploader` wraps. Providers open different backends (memory, filesystem, S3, GCS) - * behind this identical surface; {@link Uploader} layers the circuit breaker, metrics, and tracing - * on top, exactly as the Go code layers them over gocloud's bucket. - * - * Not every backend supports every operation: the in-memory and unsigned filesystem buckets reject - * {@link Bucket.signedURL} with {@link SigningUnsupportedError}, mirroring gocloud's `memblob` and - * unsigned `fileblob`. - */ -export interface Bucket { - /** Writes `body` to `key`, replacing any existing object. */ - write(key: string, body: BlobBody, opts?: SaveOptions): Promise; - /** - * Returns a stream over `length` bytes of the object at `key`, starting at `offset`. A negative - * `length` reads to the end. `openRange(key, 0, -1)` reads the whole object. Rejects with - * {@link BlobNotFoundError} when `key` is absent. - */ - openRange( - key: string, - offset: number, - length: number, - ): Promise>; - /** Removes the object at `key`. */ - delete(key: string): Promise; - /** Reports whether an object exists at `key`. */ - exists(key: string): Promise; - /** Fetches the stored metadata for `key`. Rejects with {@link BlobNotFoundError} when absent. */ - attributes(key: string): Promise; - /** Streams the objects stored under `prefix`, lazily. */ - list(prefix: string): AsyncIterable; - /** Mints a signed URL for `key`, or rejects with {@link SigningUnsupportedError}. */ - signedURL(key: string, opts?: SignedURLOptions): Promise; -} - -/** Rejected by a bucket when a key is absent — the analogue of gocloud's `NotFound` code. */ -export class BlobNotFoundError extends PlatformError { - constructor(key: string) { - super("uploads/not-found", `blob not found: ${key}`); - this.name = "BlobNotFoundError"; - } -} - -/** Rejected when a bucket cannot sign URLs (in-memory, unsigned filesystem). */ -export class SigningUnsupportedError extends PlatformError { - constructor(provider: string) { - super( - "uploads/signing-unsupported", - `${provider} bucket does not support signed URLs`, - ); - this.name = "SigningUnsupportedError"; - } -} - -/** - * Wraps a {@link Bucket} so every key is transparently prefixed — the port of Go's - * `blob.PrefixedBucket`. Applied by the factory when `bucketPrefix` is set, so provider code never - * has to know about it. Like Go, it strips the prefix back off keys returned by {@link Bucket.list}. - */ -export class PrefixedBucket implements Bucket { - readonly #inner: Bucket; - readonly #prefix: string; - - constructor(inner: Bucket, prefix: string) { - this.#inner = inner; - this.#prefix = prefix; - } - - write(key: string, body: BlobBody, opts?: SaveOptions): Promise { - return this.#inner.write(this.#prefix + key, body, opts); - } - - openRange( - key: string, - offset: number, - length: number, - ): Promise> { - return this.#inner.openRange(this.#prefix + key, offset, length); - } - - delete(key: string): Promise { - return this.#inner.delete(this.#prefix + key); - } - - exists(key: string): Promise { - return this.#inner.exists(this.#prefix + key); - } - - attributes(key: string): Promise { - return this.#inner.attributes(this.#prefix + key); - } - - async *list(prefix: string): AsyncIterable { - for await (const obj of this.#inner.list(this.#prefix + prefix)) { - yield obj.path.startsWith(this.#prefix) - ? { ...obj, path: obj.path.slice(this.#prefix.length) } - : obj; - } - } - - signedURL(key: string, opts?: SignedURLOptions): Promise { - return this.#inner.signedURL(this.#prefix + key, opts); - } -} diff --git a/packages/uploads/src/capabilities.ts b/packages/uploads/src/capabilities.ts deleted file mode 100644 index 9e4bbd8..0000000 --- a/packages/uploads/src/capabilities.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { UploadManager } from "./uploads.js"; - -/** - * Optional capabilities beyond the core {@link UploadManager}. The core guarantees only - * save/open/delete/exists; richer backends (the object-store {@link Uploader}, the noop manager) - * also implement these. Callers that need one either accept the specific interface or narrow an - * `UploadManager` with the `is*` guards below — the TypeScript analogue of Go's type assertions. - */ - -/** - * Opens a byte range of an object — for partial reads such as HTTP Range requests (video) or - * seeking within columnar files (parquet). - */ -export interface RangeReader { - /** - * Returns a stream over `length` bytes of the object at `path`, starting at `offset`. A - * negative `length` reads to the end of the object. - */ - openRange( - path: string, - offset: number, - length: number, - ): Promise>; -} - -/** - * Mints a signed URL granting temporary, direct access to an object, letting clients read or - * write storage without proxying bytes through the service. - */ -export interface URLSigner { - signedURL(path: string, opts?: SignedURLOptions): Promise; -} - -/** Fetches an object's stored metadata. */ -export interface Attributer { - attributes(path: string): Promise; -} - -/** - * Streams the objects stored under a prefix. The returned iterator yields each object lazily; a - * failure throws mid-iteration, and the caller may stop early by breaking out of the loop. - */ -export interface Lister { - list(prefix: string): AsyncIterable; -} - -/** Configures a signed URL — the port of Go's `SignedURLOptions`. */ -export interface SignedURLOptions { - /** The HTTP method the URL permits. Defaults to `"GET"`. */ - method?: "GET" | "PUT" | "DELETE"; - /** For PUT URLs, the exact `Content-Type` the client must send. */ - contentType?: string; - /** How long the URL is valid, in milliseconds. Omitted/zero means the provider default. */ - expiry?: number; -} - -/** Describes a stored object — the port of Go's `Attributes`. */ -export interface Attributes { - /** Last-modified time, when the provider reports one. */ - modTime?: Date; - contentType?: string; - cacheControl?: string; - etag?: string; - /** Size in bytes. */ - size: number; -} - -/** A single entry returned by {@link Lister.list} — the port of Go's `ObjectInfo`. */ -export interface ObjectInfo { - /** Last-modified time, when the provider reports one. */ - modTime?: Date; - path: string; - size: number; - isDir: boolean; -} - -/** - * Drains a {@link Lister} into an array — the port of Go's `uploads.ListAll`. A convenience for - * small listings; prefer iterating {@link Lister.list} directly when a prefix may hold very many - * objects. - */ -export async function listAll(l: Lister, prefix: string): Promise { - const out: ObjectInfo[] = []; - for await (const obj of l.list(prefix)) { - out.push(obj); - } - return out; -} - -/** Narrows a manager to a {@link RangeReader}. */ -export function isRangeReader(m: UploadManager): m is UploadManager & RangeReader { - return typeof (m as Partial).openRange === "function"; -} - -/** Narrows a manager to a {@link URLSigner}. */ -export function isURLSigner(m: UploadManager): m is UploadManager & URLSigner { - return typeof (m as Partial).signedURL === "function"; -} - -/** Narrows a manager to an {@link Attributer}. */ -export function isAttributer(m: UploadManager): m is UploadManager & Attributer { - return typeof (m as Partial).attributes === "function"; -} - -/** Narrows a manager to a {@link Lister}. */ -export function isLister(m: UploadManager): m is UploadManager & Lister { - return typeof (m as Partial).list === "function"; -} diff --git a/packages/uploads/src/config.ts b/packages/uploads/src/config.ts deleted file mode 100644 index 63b8b6b..0000000 --- a/packages/uploads/src/config.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { CircuitBreakerConfigSchema } from "@primandproper/circuitbreaking"; -import { z } from "zod"; - -/** Provider identifiers, mirroring Go's per-adapter provider constants. */ -export const S3_PROVIDER = "s3"; -export const FILESYSTEM_PROVIDER = "filesystem"; -export const MEMORY_PROVIDER = "memory"; -export const GCP_PROVIDER = "gcp"; -export const R2_PROVIDER = "r2"; -export const BACKBLAZE_B2_PROVIDER = "backblaze_b2"; - -/** Filesystem-provider config: blobs are written under `rootDirectory`. */ -export const FilesystemConfigSchema = z.object({ - rootDirectory: z.string(), -}); -export type FilesystemConfig = z.infer; - -/** - * Cloudflare R2 config — an S3-compatible endpoint keyed by account, with static credentials. - * The bucket to open is the top-level `bucketName`, mirroring Go's single `BucketName` field. - */ -export const R2ConfigSchema = z.object({ - accountID: z.string(), - accessKeyID: z.string(), - secretAccessKey: z.string(), -}); -export type R2Config = z.infer; - -/** Backblaze B2 config — an S3-compatible endpoint keyed by region, with application keys. */ -export const BackblazeB2ConfigSchema = z.object({ - applicationKeyID: z.string(), - applicationKey: z.string(), - region: z.string(), -}); -export type BackblazeB2Config = z.infer; - -/** - * Upload-manager config. Replaces the Go `env:`-tagged struct + ozzo `ValidateWithContext`. - * - * `bucketName` names the manager for metrics/tracing (`_uploader`), is the bucket - * every backend opens, and is required. `bucketPrefix`, when set, is transparently prepended to - * every key (Go's `blob.PrefixedBucket`). `circuitBreaker` feeds `@primandproper/circuitbreaking`'s - * `provideCircuitBreaker`. `memory` (default) keeps blobs in a `Map`; `filesystem` writes under a - * root dir; `s3` and `gcp` resolve ambient credentials; `r2`/`backblaze_b2` speak S3 via a custom - * endpoint and so carry their own credentials. - */ -export const UploadsConfigSchema = z - .object({ - provider: z - .enum([ - MEMORY_PROVIDER, - FILESYSTEM_PROVIDER, - S3_PROVIDER, - GCP_PROVIDER, - R2_PROVIDER, - BACKBLAZE_B2_PROVIDER, - ]) - .default(MEMORY_PROVIDER), - bucketName: z.string().min(1, "bucketName is required"), - bucketPrefix: z.string().default(""), - /** - * Maximum bytes accepted by a single `save`. `0` (default) disables the backstop. A byte - * body over the limit is rejected before the write; a stream body errors mid-transfer once it - * crosses the limit (no full-payload buffering). - */ - maxSizeBytes: z.number().int().nonnegative().default(0), - filesystem: FilesystemConfigSchema.optional(), - r2: R2ConfigSchema.optional(), - backblazeB2: BackblazeB2ConfigSchema.optional(), - circuitBreaker: CircuitBreakerConfigSchema.optional(), - }) - .superRefine((cfg, ctx) => { - const requireSub = ( - provider: string, - key: "filesystem" | "r2" | "backblazeB2", - present: boolean, - ): void => { - if (cfg.provider === provider && !present) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [key], - message: `${key} config is required when provider is '${provider}'`, - }); - } - }; - requireSub(FILESYSTEM_PROVIDER, "filesystem", cfg.filesystem !== undefined); - requireSub(R2_PROVIDER, "r2", cfg.r2 !== undefined); - requireSub(BACKBLAZE_B2_PROVIDER, "backblazeB2", cfg.backblazeB2 !== undefined); - }); - -export type UploadsConfig = z.infer; -export type UploadsConfigInput = z.input; diff --git a/packages/uploads/src/index.ts b/packages/uploads/src/index.ts deleted file mode 100644 index 5f22eff..0000000 --- a/packages/uploads/src/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { ObservabilityDeps } from "@primandproper/observability"; - -import { PrefixedBucket, type Bucket } from "./bucket.js"; -import { - UploadsConfigSchema, - type UploadsConfig, - type UploadsConfigInput, -} from "./config.js"; -import { FilesystemBucket } from "./providers/filesystem.js"; -import { GCSBucket } from "./providers/gcp.js"; -import { MemoryBucket } from "./providers/memory.js"; -import { newBackblazeBucket, newR2Bucket, newS3Bucket } from "./providers/s3.js"; -import { Uploader } from "./uploader.js"; -import type { UploadManager } from "./uploads.js"; - -export * from "./uploads.js"; -export * from "./capabilities.js"; -export * from "./config.js"; -export { - PrefixedBucket, - BlobNotFoundError, - SigningUnsupportedError, - type Bucket, -} from "./bucket.js"; -export { type BlobBody } from "./stream.js"; -export { - Uploader, - newCircuitBrokenError, - CIRCUIT_BROKEN_CODE, - newFileTooLargeError, - FILE_TOO_LARGE_CODE, -} from "./uploader.js"; -export { MemoryBucket } from "./providers/memory.js"; -export { FilesystemBucket } from "./providers/filesystem.js"; -export { GCSBucket } from "./providers/gcp.js"; -export { - S3Bucket, - newS3Bucket, - newR2Bucket, - newBackblazeBucket, -} from "./providers/s3.js"; -export { NoopUploadManager } from "./providers/noop.js"; - -/** Narrows a per-provider sub-config the schema's `superRefine` has already guaranteed present. */ -function required(value: T | undefined, provider: string): T { - if (value === undefined) { - throw new Error(`${provider} config is required when provider is '${provider}'`); - } - return value; -} - -/** Opens the raw {@link Bucket} for the configured provider, before prefixing/instrumentation. */ -function selectBucket(cfg: UploadsConfig): Bucket { - switch (cfg.provider) { - case "memory": - return new MemoryBucket(); - case "filesystem": - return new FilesystemBucket(required(cfg.filesystem, "filesystem").rootDirectory); - case "s3": - return newS3Bucket(cfg.bucketName); - case "gcp": - return new GCSBucket(cfg.bucketName); - case "r2": - return newR2Bucket(required(cfg.r2, "r2"), cfg.bucketName); - case "backblaze_b2": - return newBackblazeBucket( - required(cfg.backblazeB2, "backblaze_b2"), - cfg.bucketName, - ); - } -} - -/** - * Validates config and returns an instrumented {@link UploadManager} — the port of Go's - * `objectstorage.NewUploadManager` + `ProvideUploadManager`. Opens the provider's {@link Bucket}, - * wraps it in a {@link PrefixedBucket} when `bucketPrefix` is set, and hands it to an - * {@link Uploader}. Supports `memory` (default), `filesystem`, `s3`, `gcp`, `r2`, and `backblaze_b2`. - * - * The returned manager also implements the optional capabilities; narrow it with `isLister` &c. - * to reach them. For a no-op manager, construct {@link NoopUploadManager} directly. - */ -export function provideUploads( - config?: UploadsConfigInput, - deps?: ObservabilityDeps, -): UploadManager { - const cfg = UploadsConfigSchema.parse(config ?? {}); - const raw = selectBucket(cfg); - const bucket = cfg.bucketPrefix ? new PrefixedBucket(raw, cfg.bucketPrefix) : raw; - return new Uploader(cfg, bucket, deps); -} diff --git a/packages/uploads/src/providers/filesystem.ts b/packages/uploads/src/providers/filesystem.ts deleted file mode 100644 index f9cc353..0000000 --- a/packages/uploads/src/providers/filesystem.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { createReadStream, createWriteStream } from "node:fs"; -import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; -import { dirname, isAbsolute, join, posix, resolve, sep } from "node:path"; -import { Readable } from "node:stream"; -import { pipeline } from "node:stream/promises"; - -import { BlobNotFoundError, SigningUnsupportedError, type Bucket } from "../bucket.js"; -import type { Attributes, ObjectInfo } from "../capabilities.js"; -import { bytesToStream, type BlobBody } from "../stream.js"; -import type { SaveOptions } from "../uploads.js"; - -/** Suffix of the sidecar file that carries an object's metadata, mirroring gocloud's `fileblob`. */ -const ATTRS_SUFFIX = ".attrs"; - -interface SidecarAttrs { - contentType: string | undefined; - cacheControl: string | undefined; -} - -/** - * A {@link Bucket} that writes blobs under a root directory — the port of gocloud's `fileblob` - * with `CreateDir: true`. Keys are resolved against the root and rejected if they escape it, so a - * key can never traverse outside; intermediate directories are created on write. - * - * As gocloud does, per-object metadata (content type, cache control) is persisted in a sibling - * `.attrs` JSON sidecar; those files are excluded from listings. Signing is unsupported (Go - * opens the bucket with a nil `URLSigner`). - */ -export class FilesystemBucket implements Bucket { - readonly #root: string; - - constructor(rootDirectory: string) { - this.#root = resolve(rootDirectory); - } - - async write(key: string, body: BlobBody, opts?: SaveOptions): Promise { - const path = this.#pathFor(key); - await mkdir(dirname(path), { recursive: true }); - - // Write to a temp sibling then atomically rename into place, so a crash mid-write never leaves - // a partial/corrupt object at `path` (gocloud fileblob semantics). Stream the body so a large - // payload isn't buffered whole. On any failure the temp file is cleaned up. - const tmp = `${path}.${randomUUID()}.tmp`; - const source = body instanceof Uint8Array ? bytesToStream(body) : body; - try { - await pipeline(Readable.fromWeb(source), createWriteStream(tmp)); - await rename(tmp, path); - } catch (err) { - await rm(tmp, { force: true }); - throw err; - } - - const attrs: SidecarAttrs = { - contentType: opts?.contentType, - cacheControl: opts?.cacheControl, - }; - if (attrs.contentType !== undefined || attrs.cacheControl !== undefined) { - await writeFile(path + ATTRS_SUFFIX, JSON.stringify(attrs)); - } else { - await rm(path + ATTRS_SUFFIX, { force: true }); - } - } - - async openRange( - key: string, - offset: number, - length: number, - ): Promise> { - const path = this.#pathFor(key); - // stat first so an absent object rejects up front rather than erroring mid-stream. - try { - await stat(path); - } catch (err) { - if (isNotFound(err)) { - throw new BlobNotFoundError(key); - } - throw err; - } - const end = length < 0 ? undefined : offset + length - 1; - const nodeStream = createReadStream(path, { start: offset, end }); - return Readable.toWeb(nodeStream) as ReadableStream; - } - - async delete(key: string): Promise { - const path = this.#pathFor(key); - await rm(path, { force: true }); - await rm(path + ATTRS_SUFFIX, { force: true }); - } - - async exists(key: string): Promise { - try { - await stat(this.#pathFor(key)); - return true; - } catch (err) { - if (isNotFound(err)) { - return false; - } - throw err; - } - } - - async attributes(key: string): Promise { - const path = this.#pathFor(key); - let info; - try { - info = await stat(path); - } catch (err) { - if (isNotFound(err)) { - throw new BlobNotFoundError(key); - } - throw err; - } - const sidecar = await this.#readSidecar(path); - return { - size: info.size, - modTime: info.mtime, - ...(sidecar.contentType !== undefined && { contentType: sidecar.contentType }), - ...(sidecar.cacheControl !== undefined && { cacheControl: sidecar.cacheControl }), - }; - } - - async *list(prefix: string): AsyncIterable { - // Walk the whole tree, then filter by prefix — gocloud's fileblob lists the same way. - for await (const relPath of this.#walk(this.#root)) { - if (relPath.endsWith(ATTRS_SUFFIX) || !relPath.startsWith(prefix)) { - continue; - } - const info = await stat(join(this.#root, relPath)); - yield { - path: relPath, - size: info.size, - modTime: info.mtime, - isDir: false, - }; - } - } - - signedURL(): Promise { - return Promise.reject(new SigningUnsupportedError("filesystem")); - } - - async *#walk(dir: string): AsyncIterable { - let entries; - try { - entries = await readdir(dir, { withFileTypes: true }); - } catch (err) { - if (isNotFound(err)) { - return; - } - throw err; - } - for (const entry of entries) { - const abs = join(dir, entry.name); - if (entry.isDirectory()) { - yield* this.#walk(abs); - } else { - // Keys are always reported with forward slashes, regardless of platform separator. - yield abs - .slice(this.#root.length + 1) - .split(sep) - .join(posix.sep); - } - } - } - - async #readSidecar(path: string): Promise { - try { - return JSON.parse(await readFile(path + ATTRS_SUFFIX, "utf8")) as SidecarAttrs; - } catch (err) { - if (isNotFound(err)) { - return { contentType: undefined, cacheControl: undefined }; - } - throw err; - } - } - - #pathFor(key: string): string { - if (key.length === 0 || isAbsolute(key) || key.split(/[/\\]/).includes("..")) { - throw new Error(`invalid blob key: ${key}`); - } - const path = resolve(join(this.#root, key)); - if (path !== this.#root && !path.startsWith(this.#root + sep)) { - throw new Error(`invalid blob key: ${key}`); - } - return path; - } -} - -function isNotFound(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - (err as { code?: unknown }).code === "ENOENT" - ); -} diff --git a/packages/uploads/src/providers/gcp.ts b/packages/uploads/src/providers/gcp.ts deleted file mode 100644 index 6f83c32..0000000 --- a/packages/uploads/src/providers/gcp.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Readable } from "node:stream"; -import { pipeline } from "node:stream/promises"; - -import { Storage, type Bucket as GCSBucketHandle } from "@google-cloud/storage"; -import { wrap } from "@primandproper/errors"; - -import { BlobNotFoundError, type Bucket } from "../bucket.js"; -import type { Attributes, ObjectInfo, SignedURLOptions } from "../capabilities.js"; -import { bytesToStream, type BlobBody } from "../stream.js"; -import type { SaveOptions } from "../uploads.js"; - -/** HTTP status GCS returns for an absent object. */ -const NOT_FOUND = 404; - -/** - * A {@link Bucket} backed by Google Cloud Storage — the port of gocloud's `gcsblob`. The client - * resolves credentials from Application Default Credentials, matching Go's `gcp.DefaultCredentials`. - * Both reads and writes stream: a write pipes the body into the GCS resumable upload stream rather - * than buffering the whole payload in memory. - */ -export class GCSBucket implements Bucket { - readonly #bucket: GCSBucketHandle; - - constructor(bucketName: string, storage: Storage = new Storage()) { - this.#bucket = storage.bucket(bucketName); - } - - async write(key: string, body: BlobBody, opts?: SaveOptions): Promise { - const writeStream = this.#bucket.file(key).createWriteStream({ - ...(opts?.contentType !== undefined && { contentType: opts.contentType }), - ...(opts?.cacheControl !== undefined && { - metadata: { cacheControl: opts.cacheControl }, - }), - }); - const source = body instanceof Uint8Array ? bytesToStream(body) : body; - try { - await pipeline(Readable.fromWeb(source), writeStream); - } catch (err) { - throw wrap(`gcs write failed for key '${key}'`, err); - } - } - - async openRange( - key: string, - offset: number, - length: number, - ): Promise> { - // The GCS read stream errors lazily on a missing object; check up front so `open` rejects. - if (!(await this.exists(key))) { - throw new BlobNotFoundError(key); - } - const range = - length < 0 ? { start: offset } : { start: offset, end: offset + length - 1 }; - const nodeStream = this.#bucket.file(key).createReadStream(range); - return Readable.toWeb(nodeStream) as ReadableStream; - } - - async delete(key: string): Promise { - try { - await this.#bucket.file(key).delete({ ignoreNotFound: true }); - } catch (err) { - throw wrap(`gcs delete failed for key '${key}'`, err); - } - } - - async exists(key: string): Promise { - try { - const [exists] = await this.#bucket.file(key).exists(); - return exists; - } catch (err) { - throw wrap(`gcs exists check failed for key '${key}'`, err); - } - } - - async attributes(key: string): Promise { - let metadata; - try { - [metadata] = await this.#bucket.file(key).getMetadata(); - } catch (err) { - if (isNotFound(err)) { - throw new BlobNotFoundError(key); - } - throw wrap(`gcs attributes fetch failed for key '${key}'`, err); - } - return { - size: metadata.size !== undefined ? Number(metadata.size) : 0, - ...(metadata.contentType !== undefined && { contentType: metadata.contentType }), - ...(metadata.cacheControl !== undefined && { cacheControl: metadata.cacheControl }), - ...(metadata.etag !== undefined && { etag: metadata.etag }), - ...(metadata.updated !== undefined && { modTime: new Date(metadata.updated) }), - }; - } - - async *list(prefix: string): AsyncIterable { - let pageToken: string | undefined; - do { - const query = pageToken !== undefined ? { prefix, pageToken } : { prefix }; - let response; - try { - response = await this.#bucket.getFiles(query); - } catch (err) { - throw wrap(`gcs list failed for prefix '${prefix}'`, err); - } - const [files, nextQuery] = response; - for (const file of files) { - const updated = file.metadata.updated; - yield { - path: file.name, - size: file.metadata.size !== undefined ? Number(file.metadata.size) : 0, - isDir: false, - ...(updated !== undefined && { modTime: new Date(updated) }), - }; - } - pageToken = (nextQuery as { pageToken?: string } | null | undefined)?.pageToken; - } while (pageToken !== undefined); - } - - async signedURL(key: string, opts?: SignedURLOptions): Promise { - const action = - opts?.method === "PUT" ? "write" : opts?.method === "DELETE" ? "delete" : "read"; - const expiry = - opts?.expiry !== undefined && opts.expiry > 0 ? opts.expiry : 15 * 60 * 1000; - try { - const [url] = await this.#bucket.file(key).getSignedUrl({ - version: "v4", - action, - expires: Date.now() + expiry, - ...(opts?.contentType !== undefined && { contentType: opts.contentType }), - }); - return url; - } catch (err) { - throw wrap(`gcs signing failed for key '${key}'`, err); - } - } -} - -function isNotFound(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - (err as { code?: unknown }).code === NOT_FOUND - ); -} diff --git a/packages/uploads/src/providers/memory.ts b/packages/uploads/src/providers/memory.ts deleted file mode 100644 index b9e458e..0000000 --- a/packages/uploads/src/providers/memory.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { BlobNotFoundError, SigningUnsupportedError, type Bucket } from "../bucket.js"; -import type { Attributes, ObjectInfo } from "../capabilities.js"; -import { bytesToStream, toBytes, type BlobBody } from "../stream.js"; -import type { SaveOptions } from "../uploads.js"; - -interface StoredBlob { - content: Uint8Array; - contentType: string | undefined; - cacheControl: string | undefined; - modTime: Date; -} - -/** - * A {@link Bucket} backed by an in-process `Map` — the port of gocloud's `memblob`, and the - * default provider. Signing is unsupported, exactly as with `memblob`. - */ -export class MemoryBucket implements Bucket { - readonly #blobs = new Map(); - - async write(key: string, body: BlobBody, opts?: SaveOptions): Promise { - const content = (await toBytes(body)).slice(); - this.#blobs.set(key, { - content, - contentType: opts?.contentType, - cacheControl: opts?.cacheControl, - modTime: new Date(), - }); - } - - openRange( - key: string, - offset: number, - length: number, - ): Promise> { - const stored = this.#blobs.get(key); - if (stored === undefined) { - return Promise.reject(new BlobNotFoundError(key)); - } - const end = length < 0 ? stored.content.length : offset + length; - return Promise.resolve(bytesToStream(stored.content.slice(offset, end))); - } - - delete(key: string): Promise { - this.#blobs.delete(key); - return Promise.resolve(); - } - - exists(key: string): Promise { - return Promise.resolve(this.#blobs.has(key)); - } - - attributes(key: string): Promise { - const stored = this.#blobs.get(key); - if (stored === undefined) { - return Promise.reject(new BlobNotFoundError(key)); - } - return Promise.resolve({ - size: stored.content.length, - modTime: stored.modTime, - ...(stored.contentType !== undefined && { contentType: stored.contentType }), - ...(stored.cacheControl !== undefined && { cacheControl: stored.cacheControl }), - }); - } - - async *list(prefix: string): AsyncIterable { - for (const [key, stored] of this.#blobs) { - if (key.startsWith(prefix)) { - yield { - path: key, - size: stored.content.length, - modTime: stored.modTime, - isDir: false, - }; - } - } - } - - signedURL(): Promise { - return Promise.reject(new SigningUnsupportedError("memory")); - } -} diff --git a/packages/uploads/src/providers/noop.ts b/packages/uploads/src/providers/noop.ts deleted file mode 100644 index 8477722..0000000 --- a/packages/uploads/src/providers/noop.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { SigningUnsupportedError } from "../bucket.js"; -import type { - Attributer, - Attributes, - Lister, - ObjectInfo, - RangeReader, - URLSigner, -} from "../capabilities.js"; -import { drain, emptyStream, type BlobBody } from "../stream.js"; -import type { UploadManager } from "../uploads.js"; - -/** - * An {@link UploadManager} that stores nothing — the port of Go's `uploads/noop` package. It - * implements the full capability surface so it can stand in for any manager: saves drain the - * body and vanish, reads yield an empty stream, existence is always `false`, listing is empty. - * - * Unlike the config-selected providers, noop is constructed directly (Go's objectstorage config - * has no `noop` provider); reach for it in tests or to disable uploads without changing call sites. - */ -export class NoopUploadManager - implements UploadManager, RangeReader, URLSigner, Attributer, Lister -{ - save(_path: string, body: BlobBody): Promise { - return drain(body); - } - - open(): Promise> { - return Promise.resolve(emptyStream()); - } - - openRange(): Promise> { - return Promise.resolve(emptyStream()); - } - - delete(): Promise { - return Promise.resolve(); - } - - exists(): Promise { - return Promise.resolve(false); - } - - attributes(): Promise { - return Promise.resolve({ size: 0 }); - } - - async *list(): AsyncIterable { - // Intentionally empty: a noop manager lists no objects. - } - - signedURL(): Promise { - // A noop manager can't sign a URL — reject rather than hand back a bogus empty string that a - // caller might treat as a working URL. Mirrors how config-selected buckets refuse signing. - return Promise.reject(new SigningUnsupportedError("noop")); - } -} diff --git a/packages/uploads/src/providers/s3-stream.test.ts b/packages/uploads/src/providers/s3-stream.test.ts deleted file mode 100644 index 73c2832..0000000 --- a/packages/uploads/src/providers/s3-stream.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Readable } from "node:stream"; - -import type { S3Client } from "@aws-sdk/client-s3"; -import { describe, expect, it, vi } from "vitest"; - -import { bytesToStream } from "../stream.js"; - -import { S3Bucket } from "./s3.js"; - -// Capture how S3Bucket drives lib-storage's Upload without exercising the real SDK internals -// (endpoint resolution, request handler), which a hand-rolled fake client can't satisfy. -const h = vi.hoisted(() => ({ - calls: [] as { params: Record }[], -})); - -vi.mock("@aws-sdk/lib-storage", () => ({ - Upload: class { - constructor(opts: { params: Record }) { - h.calls.push({ params: opts.params }); - } - done(): Promise { - return Promise.resolve(); - } - }, -})); - -describe("S3Bucket stream write routing (UP-1)", () => { - it("streams an unknown-length body through Upload rather than buffering it", async () => { - h.calls.length = 0; - // A client that would throw if `send` (the single-PutObject path) were used for a stream. - const client = { - send: () => Promise.reject(new Error("stream body must not use PutObject")), - } as unknown as S3Client; - - const bucket = new S3Bucket(client, "bucket"); - await bucket.write("big.bin", bytesToStream(new Uint8Array([1, 2, 3])), { - contentType: "application/octet-stream", - }); - - expect(h.calls).toHaveLength(1); - const params = h.calls[0]?.params; - expect(params?.Bucket).toBe("bucket"); - expect(params?.Key).toBe("big.bin"); - expect(params?.ContentType).toBe("application/octet-stream"); - // The body handed to Upload is a Node stream, not a buffered Uint8Array. - expect(params?.Body).toBeInstanceOf(Readable); - }); -}); diff --git a/packages/uploads/src/providers/s3.ts b/packages/uploads/src/providers/s3.ts deleted file mode 100644 index 8aecd48..0000000 --- a/packages/uploads/src/providers/s3.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { Readable } from "node:stream"; - -import { - DeleteObjectCommand, - GetObjectCommand, - HeadObjectCommand, - ListObjectsV2Command, - PutObjectCommand, - S3Client, - type S3ClientConfig, -} from "@aws-sdk/client-s3"; -import { Upload } from "@aws-sdk/lib-storage"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { wrap } from "@primandproper/errors"; - -import { BlobNotFoundError, type Bucket } from "../bucket.js"; -import type { Attributes, ObjectInfo, SignedURLOptions } from "../capabilities.js"; -import type { BackblazeB2Config, R2Config } from "../config.js"; -import type { BlobBody } from "../stream.js"; -import type { SaveOptions } from "../uploads.js"; - -/** - * A {@link Bucket} backed by S3 or any S3-compatible service. The port of gocloud's `s3blob`, - * and — as in platform-go, where R2 and Backblaze B2 also open through `s3blob` with a custom - * endpoint — the single implementation behind the `s3`, `r2`, and `backblaze_b2` providers. - * - * Both reads and writes stream without buffering a whole object. A bytes body is already fully in - * memory, so it takes a single `PutObject`; a stream body of unknown length goes through - * `@aws-sdk/lib-storage`'s `Upload`, which multiparts it rather than draining it into memory. A - * missing object is normalized to {@link BlobNotFoundError}; every other SDK failure is rethrown - * wrapped with context. - */ -export class S3Bucket implements Bucket { - readonly #client: S3Client; - readonly #bucket: string; - - constructor(client: S3Client, bucketName: string) { - this.#client = client; - this.#bucket = bucketName; - } - - async write(key: string, body: BlobBody, opts?: SaveOptions): Promise { - try { - if (body instanceof Uint8Array) { - await this.#client.send( - new PutObjectCommand({ - Bucket: this.#bucket, - Key: key, - Body: body, - ContentType: opts?.contentType, - CacheControl: opts?.cacheControl, - }), - ); - return; - } - await new Upload({ - client: this.#client, - params: { - Bucket: this.#bucket, - Key: key, - Body: Readable.fromWeb(body), - ContentType: opts?.contentType, - CacheControl: opts?.cacheControl, - }, - }).done(); - } catch (err) { - throw wrap(`s3 write failed for key '${key}'`, err); - } - } - - async openRange( - key: string, - offset: number, - length: number, - ): Promise> { - let response; - try { - response = await this.#client.send( - new GetObjectCommand({ - Bucket: this.#bucket, - Key: key, - Range: rangeHeader(offset, length), - }), - ); - } catch (err) { - if (isNotFound(err)) { - throw new BlobNotFoundError(key); - } - throw wrap(`s3 read failed for key '${key}'`, err); - } - - if (response.Body === undefined) { - throw new BlobNotFoundError(key); - } - return response.Body.transformToWebStream(); - } - - async delete(key: string): Promise { - try { - await this.#client.send( - new DeleteObjectCommand({ Bucket: this.#bucket, Key: key }), - ); - } catch (err) { - throw wrap(`s3 delete failed for key '${key}'`, err); - } - } - - async exists(key: string): Promise { - try { - await this.#client.send(new HeadObjectCommand({ Bucket: this.#bucket, Key: key })); - return true; - } catch (err) { - if (isNotFound(err)) { - return false; - } - throw wrap(`s3 exists check failed for key '${key}'`, err); - } - } - - async attributes(key: string): Promise { - let head; - try { - head = await this.#client.send( - new HeadObjectCommand({ Bucket: this.#bucket, Key: key }), - ); - } catch (err) { - if (isNotFound(err)) { - throw new BlobNotFoundError(key); - } - throw wrap(`s3 attributes fetch failed for key '${key}'`, err); - } - return { - size: head.ContentLength ?? 0, - ...(head.ContentType !== undefined && { contentType: head.ContentType }), - ...(head.CacheControl !== undefined && { cacheControl: head.CacheControl }), - ...(head.ETag !== undefined && { etag: head.ETag }), - ...(head.LastModified !== undefined && { modTime: head.LastModified }), - }; - } - - async *list(prefix: string): AsyncIterable { - let continuationToken: string | undefined; - do { - let page; - try { - page = await this.#client.send( - new ListObjectsV2Command({ - Bucket: this.#bucket, - Prefix: prefix, - ContinuationToken: continuationToken, - }), - ); - } catch (err) { - throw wrap(`s3 list failed for prefix '${prefix}'`, err); - } - for (const obj of page.Contents ?? []) { - if (obj.Key === undefined) { - continue; - } - yield { - path: obj.Key, - size: obj.Size ?? 0, - isDir: false, - ...(obj.LastModified !== undefined && { modTime: obj.LastModified }), - }; - } - continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined; - } while (continuationToken !== undefined); - } - - async signedURL(key: string, opts?: SignedURLOptions): Promise { - const method = opts?.method ?? "GET"; - const command = - method === "PUT" - ? new PutObjectCommand({ - Bucket: this.#bucket, - Key: key, - ContentType: opts?.contentType, - }) - : method === "DELETE" - ? new DeleteObjectCommand({ Bucket: this.#bucket, Key: key }) - : new GetObjectCommand({ Bucket: this.#bucket, Key: key }); - - const expiresIn = - opts?.expiry !== undefined && opts.expiry > 0 - ? Math.ceil(opts.expiry / 1000) - : undefined; - try { - return await getSignedUrl( - this.#client, - command, - expiresIn !== undefined ? { expiresIn } : {}, - ); - } catch (err) { - throw wrap(`s3 signing failed for key '${key}'`, err); - } - } -} - -/** Opens a plain S3 bucket, letting the SDK resolve region/credentials from its default chain. */ -export function newS3Bucket(bucketName: string): S3Bucket { - return new S3Bucket(new S3Client({}), bucketName); -} - -/** Opens a Cloudflare R2 bucket via its account-scoped S3 endpoint with static credentials. */ -export function newR2Bucket(cfg: R2Config, bucketName: string): S3Bucket { - const config: S3ClientConfig = { - endpoint: `https://${cfg.accountID}.r2.cloudflarestorage.com`, - region: "auto", - credentials: { - accessKeyId: cfg.accessKeyID, - secretAccessKey: cfg.secretAccessKey, - }, - }; - return new S3Bucket(new S3Client(config), bucketName); -} - -/** Opens a Backblaze B2 bucket via its region-scoped S3 endpoint with an application key. */ -export function newBackblazeBucket(cfg: BackblazeB2Config, bucketName: string): S3Bucket { - const config: S3ClientConfig = { - endpoint: `https://s3.${cfg.region}.backblazeb2.com`, - region: cfg.region, - credentials: { - accessKeyId: cfg.applicationKeyID, - secretAccessKey: cfg.applicationKey, - }, - }; - return new S3Bucket(new S3Client(config), bucketName); -} - -/** Builds an HTTP Range header, or `undefined` for a full read (offset 0, negative length). */ -function rangeHeader(offset: number, length: number): string | undefined { - if (offset === 0 && length < 0) { - return undefined; - } - const start = String(offset); - return length < 0 ? `bytes=${start}-` : `bytes=${start}-${String(offset + length - 1)}`; -} - -/** True when the SDK error signals an absent object (404 / NoSuchKey / NotFound). */ -function isNotFound(err: unknown): boolean { - if (typeof err !== "object" || err === null) { - return false; - } - const name = (err as { name?: unknown }).name; - const status = (err as { $metadata?: { httpStatusCode?: unknown } }).$metadata - ?.httpStatusCode; - return name === "NoSuchKey" || name === "NotFound" || status === 404; -} diff --git a/packages/uploads/src/stream.ts b/packages/uploads/src/stream.ts deleted file mode 100644 index 5208654..0000000 --- a/packages/uploads/src/stream.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Small byte-stream helpers shared across providers and the byte-oriented convenience helpers. - * The port keeps the manager streaming (Go's `io.Reader`/`io.ReadCloser`) while still offering a - * `Uint8Array` path, so these convert between the two representations. - */ - -/** A blob payload: raw bytes, or a stream of byte chunks for content that shouldn't be buffered. */ -export type BlobBody = Uint8Array | ReadableStream; - -/** Wraps a byte slice in a single-chunk {@link ReadableStream}. */ -export function bytesToStream(bytes: Uint8Array): ReadableStream { - return new ReadableStream({ - start(controller) { - controller.enqueue(bytes); - controller.close(); - }, - }); -} - -/** A stream that yields no bytes — the analogue of Go noop's empty `io.ReadCloser`. */ -export function emptyStream(): ReadableStream { - return new ReadableStream({ - start(controller) { - controller.close(); - }, - }); -} - -/** Reads a byte stream to completion, concatenating every chunk into one `Uint8Array`. */ -export async function collectStream( - stream: ReadableStream, -): Promise { - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; - } - chunks.push(value); - total += value.length; - } - } finally { - reader.releaseLock(); - } - - const out = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - out.set(chunk, offset); - offset += chunk.length; - } - return out; -} - -/** Normalizes a {@link BlobBody} to bytes, draining a stream when necessary. */ -export function toBytes(body: BlobBody): Promise { - return body instanceof Uint8Array ? Promise.resolve(body) : collectStream(body); -} - -/** - * Wraps a byte stream so it errors once the cumulative byte count exceeds `maxBytes`, without - * buffering the whole payload. `onExceeded` supplies the error to fail the stream with — the - * caller owns the error type. Bytes up to the limit still flow to the consumer before the error. - */ -export function limitStream( - stream: ReadableStream, - maxBytes: number, - onExceeded: (limit: number) => Error, -): ReadableStream { - const reader = stream.getReader(); - let total = 0; - return new ReadableStream({ - async pull(controller) { - const { done, value } = await reader.read(); - if (done) { - controller.close(); - reader.releaseLock(); - return; - } - total += value.length; - if (total > maxBytes) { - const err = onExceeded(maxBytes); - controller.error(err); - await reader.cancel(err); - reader.releaseLock(); - return; - } - controller.enqueue(value); - }, - async cancel(reason) { - await reader.cancel(reason); - reader.releaseLock(); - }, - }); -} - -/** Discards a body's bytes — used by the noop manager to honor the read of a passed stream. */ -export async function drain(body: BlobBody): Promise { - if (body instanceof Uint8Array) { - return; - } - const reader = body.getReader(); - try { - for (;;) { - const { done } = await reader.read(); - if (done) { - break; - } - } - } finally { - reader.releaseLock(); - } -} diff --git a/packages/uploads/src/uploader.ts b/packages/uploads/src/uploader.ts deleted file mode 100644 index 02d9727..0000000 --- a/packages/uploads/src/uploader.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { - provideCircuitBreaker, - type CircuitBreaker, -} from "@primandproper/circuitbreaking"; -import { PlatformError } from "@primandproper/errors"; -import { - makeMetrics, - makeObserver, - type Metrics, - type ObservabilityDeps, - type Observer, - type Operation, -} from "@primandproper/observability"; - -import type { Bucket } from "./bucket.js"; -import type { - Attributer, - Attributes, - Lister, - ObjectInfo, - RangeReader, - SignedURLOptions, - URLSigner, -} from "./capabilities.js"; -import type { UploadsConfig } from "./config.js"; -import { limitStream, type BlobBody } from "./stream.js"; -import type { SaveOptions, UploadManager } from "./uploads.js"; - -type Counter = ReturnType; -type Histogram = ReturnType; - -/** Span/log attribute keys, mirroring Go's `observability/keys`. */ -const FILENAME_KEY = "filename"; -const LENGTH_KEY = "length"; -const PREFIX_KEY = "prefix"; -const OBJECT_COUNT_KEY = "object.count"; - -/** The stable error `code` carried by every circuit-broken error; match with `isPlatformError`. */ -export const CIRCUIT_BROKEN_CODE = "uploads/circuit-broken"; - -/** - * Mints a fresh error signalling the circuit breaker is open, short-circuiting a doomed call. The - * port of Go's `circuitbreaking.ErrCircuitBroken` (which the TS circuitbreaking package does not - * export). A factory, not a shared singleton, so each throw carries a stack captured at the throw - * site rather than one frozen at module load — match by `isPlatformError(err, CIRCUIT_BROKEN_CODE)`. - */ -export function newCircuitBrokenError(): PlatformError { - return new PlatformError(CIRCUIT_BROKEN_CODE, "circuit breaker is open"); -} - -/** The stable error `code` carried by an over-max-size write rejection. */ -export const FILE_TOO_LARGE_CODE = "uploads/file-too-large"; - -/** Mints a fresh error for a write that exceeds the configured `maxSizeBytes` backstop. */ -export function newFileTooLargeError( - limitBytes: number, - actualBytes?: number, -): PlatformError { - const seen = actualBytes === undefined ? "" : ` (got ${String(actualBytes)})`; - return new PlatformError( - FILE_TOO_LARGE_CODE, - `upload exceeds maximum size of ${String(limitBytes)} bytes${seen}`, - ); -} - -/** - * The instrumented {@link UploadManager}: a {@link Bucket} wrapped with a circuit breaker, - * save/read/error counters, and a latency histogram, each observation fanning out to a span. It - * also implements every optional capability the underlying bucket exposes ({@link RangeReader}, - * {@link URLSigner}, {@link Attributer}, {@link Lister}). The faithful analogue of platform-go's - * `objectstorage.Uploader`. - */ -export class Uploader - implements UploadManager, RangeReader, URLSigner, Attributer, Lister -{ - readonly #bucket: Bucket; - readonly #observer: Observer; - readonly #circuitBreaker: CircuitBreaker; - readonly #saveCounter: Counter; - readonly #readCounter: Counter; - readonly #saveErrCounter: Counter; - readonly #readErrCounter: Counter; - readonly #latencyHist: Histogram; - readonly #maxSizeBytes: number; - - constructor(config: UploadsConfig, bucket: Bucket, deps: ObservabilityDeps = {}) { - const serviceName = `${config.bucketName}_uploader`; - this.#bucket = bucket; - this.#maxSizeBytes = config.maxSizeBytes; - this.#observer = deps.observer ?? makeObserver(serviceName, deps); - this.#circuitBreaker = provideCircuitBreaker(config.circuitBreaker, deps); - - const metrics = makeMetrics(serviceName, deps.metrics); - this.#saveCounter = metrics.counter(`${serviceName}_saves`); - this.#readCounter = metrics.counter(`${serviceName}_reads`); - this.#saveErrCounter = metrics.counter(`${serviceName}_save_errors`); - this.#readErrCounter = metrics.counter(`${serviceName}_read_errors`); - this.#latencyHist = metrics.histogram(`${serviceName}_latency_ms`, { unit: "ms" }); - } - - save(path: string, body: BlobBody, opts?: SaveOptions): Promise { - return this.#track( - "save", - "save", - "writing file content", - (op) => { - op.set(FILENAME_KEY, path); - if (body instanceof Uint8Array) { - op.set(LENGTH_KEY, body.length); - } - }, - () => this.#bucket.write(path, this.#enforceMaxSize(body), opts), - ); - } - - /** - * Applies the `maxSizeBytes` backstop (`0` = disabled). A byte body over the limit is rejected - * up front; a stream body is wrapped so it errors mid-transfer once it crosses the limit, - * without buffering the whole payload. Errors surface as {@link newFileTooLargeError}. - */ - #enforceMaxSize(body: BlobBody): BlobBody { - if (this.#maxSizeBytes <= 0) { - return body; - } - if (body instanceof Uint8Array) { - if (body.length > this.#maxSizeBytes) { - throw newFileTooLargeError(this.#maxSizeBytes, body.length); - } - return body; - } - return limitStream(body, this.#maxSizeBytes, (limit) => newFileTooLargeError(limit)); - } - - open(path: string): Promise> { - return this.openRange(path, 0, -1); - } - - openRange( - path: string, - offset: number, - length: number, - ): Promise> { - return this.#track( - "openRange", - "read", - "opening object reader", - (op) => op.set(FILENAME_KEY, path), - () => this.#bucket.openRange(path, offset, length), - ); - } - - delete(path: string): Promise { - return this.#track( - "delete", - "save", - "deleting object", - (op) => op.set(FILENAME_KEY, path), - () => this.#bucket.delete(path), - ); - } - - exists(path: string): Promise { - return this.#track( - "exists", - "read", - "checking object existence", - (op) => op.set(FILENAME_KEY, path), - () => this.#bucket.exists(path), - ); - } - - attributes(path: string): Promise { - return this.#track( - "attributes", - "read", - "fetching object attributes", - (op) => op.set(FILENAME_KEY, path), - () => this.#bucket.attributes(path), - ); - } - - signedURL(path: string, opts?: SignedURLOptions): Promise { - return this.#track( - "signedURL", - "read", - "signing object URL", - (op) => op.set(FILENAME_KEY, path), - () => this.#bucket.signedURL(path, opts), - ); - } - - async *list(prefix: string): AsyncIterable { - const op = this.#observer.begin("list"); - const start = performance.now(); - let count = 0; - try { - op.set(PREFIX_KEY, prefix); - if (!this.#circuitBreaker.canProceed()) { - throw newCircuitBrokenError(); - } - try { - for await (const obj of this.#bucket.list(prefix)) { - count++; - yield obj; - } - } catch (err) { - this.#latencyHist.record(performance.now() - start); - this.#readErrCounter.add(1); - this.#circuitBreaker.failed(); - throw op.error(err, "listing objects"); - } - op.set(OBJECT_COUNT_KEY, count); - this.#latencyHist.record(performance.now() - start); - this.#readCounter.add(1); - this.#circuitBreaker.succeeded(); - } finally { - op.end(); - } - } - - /** - * The shared instrument/breaker envelope every single-shot method runs inside: opens a span, - * short-circuits when the breaker is open, times the delegate, and fans the outcome out to the - * right counter pair (`save`/`read`) plus the latency histogram — the port of the repeated - * bookkeeping in Go's `objectstorage/files.go`. - */ - async #track( - name: string, - kind: "save" | "read", - failureDesc: string, - setup: (op: Operation) => void, - fn: () => Promise, - ): Promise { - const op = this.#observer.begin(name); - try { - setup(op); - if (!this.#circuitBreaker.canProceed()) { - throw newCircuitBrokenError(); - } - const start = performance.now(); - try { - const result = await fn(); - this.#latencyHist.record(performance.now() - start); - (kind === "save" ? this.#saveCounter : this.#readCounter).add(1); - this.#circuitBreaker.succeeded(); - return result; - } catch (err) { - this.#latencyHist.record(performance.now() - start); - (kind === "save" ? this.#saveErrCounter : this.#readErrCounter).add(1); - this.#circuitBreaker.failed(); - throw op.error(err, failureDesc); - } - } finally { - op.end(); - } - } -} diff --git a/packages/uploads/src/uploads.test.ts b/packages/uploads/src/uploads.test.ts deleted file mode 100644 index 4fdcdce..0000000 --- a/packages/uploads/src/uploads.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { mkdtemp, readdir, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import type { S3Client } from "@aws-sdk/client-s3"; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -import { FilesystemBucket } from "./providers/filesystem.js"; -import { MemoryBucket } from "./providers/memory.js"; -import { NoopUploadManager } from "./providers/noop.js"; -import { S3Bucket } from "./providers/s3.js"; -import { bytesToStream, collectStream } from "./stream.js"; - -import { - BlobNotFoundError, - PrefixedBucket, - SigningUnsupportedError, - isAttributer, - isLister, - isRangeReader, - isURLSigner, - listAll, - provideUploads, - readFile, - saveFile, - UploadsConfigSchema, - type Attributer, - type Bucket, - type Lister, - type UploadManager, - type URLSigner, -} from "./index.js"; - -const bytes = (...n: number[]): Uint8Array => new Uint8Array(n); - -async function read(stream: ReadableStream): Promise { - return collectStream(stream); -} - -/** - * Provider-agnostic conformance suite over the {@link Bucket} seam. Running the same assertions - * against memory + filesystem proves the interface is implementation-independent. - */ -function bucketConformance(name: string, make: () => Bucket): void { - describe(name, () => { - it("rejects opening an unknown key", async () => { - await expect(make().openRange("missing", 0, -1)).rejects.toBeInstanceOf( - BlobNotFoundError, - ); - }); - - it("round-trips bytes through write then open", async () => { - const b = make(); - await b.write("a", bytes(1, 2, 3, 4)); - expect(await read(await b.openRange("a", 0, -1))).toEqual(bytes(1, 2, 3, 4)); - }); - - it("writes from a stream body", async () => { - const b = make(); - await b.write("s", bytesToStream(bytes(9, 8, 7))); - expect(await read(await b.openRange("s", 0, -1))).toEqual(bytes(9, 8, 7)); - }); - - it("reads a byte range", async () => { - const b = make(); - await b.write("r", bytes(0, 1, 2, 3, 4, 5)); - expect(await read(await b.openRange("r", 2, 3))).toEqual(bytes(2, 3, 4)); - }); - - it("reads to the end with a negative length", async () => { - const b = make(); - await b.write("r", bytes(0, 1, 2, 3, 4, 5)); - expect(await read(await b.openRange("r", 4, -1))).toEqual(bytes(4, 5)); - }); - - it("reflects state through exists", async () => { - const b = make(); - expect(await b.exists("a")).toBe(false); - await b.write("a", bytes(1)); - expect(await b.exists("a")).toBe(true); - }); - - it("removes an object through delete", async () => { - const b = make(); - await b.write("a", bytes(1)); - await b.delete("a"); - expect(await b.exists("a")).toBe(false); - }); - - it("delete is a no-op on an absent key", async () => { - await expect(make().delete("nope")).resolves.toBeUndefined(); - }); - - it("stores and reports attributes", async () => { - const b = make(); - await b.write("a", bytes(1, 2, 3), { - contentType: "text/plain", - cacheControl: "max-age=60", - }); - const attrs = await b.attributes("a"); - expect(attrs.size).toBe(3); - expect(attrs.contentType).toBe("text/plain"); - expect(attrs.cacheControl).toBe("max-age=60"); - }); - - it("rejects attributes on an absent key", async () => { - await expect(make().attributes("missing")).rejects.toBeInstanceOf( - BlobNotFoundError, - ); - }); - - it("lists objects under a prefix", async () => { - const b = make(); - await b.write("data/a.txt", bytes(1)); - await b.write("data/b.txt", bytes(2)); - await b.write("other/c.txt", bytes(3)); - const paths: string[] = []; - for await (const obj of b.list("data/")) { - paths.push(obj.path); - } - expect(paths.sort()).toEqual(["data/a.txt", "data/b.txt"]); - }); - - it("does not support signing", async () => { - await expect(make().signedURL("a")).rejects.toBeInstanceOf(SigningUnsupportedError); - }); - }); -} - -bucketConformance("MemoryBucket", () => new MemoryBucket()); - -describe("FilesystemBucket", () => { - let root: string; - let dir: string; - - beforeAll(async () => { - root = await mkdtemp(join(tmpdir(), "uploads-fs-")); - }); - - beforeEach(async () => { - dir = await mkdtemp(join(root, "case-")); - }); - - afterAll(async () => { - await rm(root, { recursive: true, force: true }); - }); - - bucketConformance("conformance", () => new FilesystemBucket(dir)); - - describe("key sanitization", () => { - it("rejects a traversal key on write", async () => { - await expect( - new FilesystemBucket(dir).write("../escape", bytes(1)), - ).rejects.toThrow(); - }); - - it("rejects an absolute key", async () => { - await expect( - new FilesystemBucket(dir).write("/etc/passwd", bytes(1)), - ).rejects.toThrow(); - }); - }); - - it("does not surface .attrs sidecars in listings", async () => { - const b = new FilesystemBucket(dir); - await b.write("note.txt", bytes(1), { contentType: "text/plain" }); - const paths = (await listAll(b, "")).map((o) => o.path); - expect(paths).toEqual(["note.txt"]); - }); - - // UP-2: the atomic temp+rename write must leave no partial/temp residue behind. - it("leaves no temp file after an atomic write", async () => { - const b = new FilesystemBucket(dir); - await b.write("data/blob.bin", bytesToStream(bytes(1, 2, 3, 4))); - expect(await read(await b.openRange("data/blob.bin", 0, -1))).toEqual( - bytes(1, 2, 3, 4), - ); - const entries = await readdir(join(dir, "data")); - expect(entries.some((name) => name.endsWith(".tmp"))).toBe(false); - }); -}); - -describe("MemoryBucket copies bytes", () => { - it("does not store a reference to the caller's buffer", async () => { - const b = new MemoryBucket(); - const body = bytes(1, 2, 3); - await b.write("a", body); - body[0] = 99; - expect(await read(await b.openRange("a", 0, -1))).toEqual(bytes(1, 2, 3)); - }); -}); - -describe("PrefixedBucket", () => { - it("prefixes keys and strips the prefix from listings", async () => { - const inner = new MemoryBucket(); - const b = new PrefixedBucket(inner, "tenant-1/"); - - await b.write("a.txt", bytes(1)); - expect(await inner.exists("tenant-1/a.txt")).toBe(true); - expect(await read(await b.openRange("a.txt", 0, -1))).toEqual(bytes(1)); - - const paths = (await listAll(b, "")).map((o) => o.path); - expect(paths).toEqual(["a.txt"]); - }); -}); - -describe("provideUploads (instrumented Uploader over memory)", () => { - it("round-trips through the byte helpers", async () => { - const m = provideUploads({ bucketName: "test" }); - await saveFile(m, "note.txt", new TextEncoder().encode("hi"), { - contentType: "text/plain", - }); - expect(new TextDecoder().decode(await readFile(m, "note.txt"))).toBe("hi"); - }); - - it("rejects reading an absent object", async () => { - const m = provideUploads({ bucketName: "test" }); - await expect(m.open("missing")).rejects.toBeInstanceOf(BlobNotFoundError); - }); - - it("exposes the optional capabilities", async () => { - const m = provideUploads({ bucketName: "test" }); - expect(isRangeReader(m)).toBe(true); - expect(isURLSigner(m)).toBe(true); - expect(isAttributer(m)).toBe(true); - expect(isLister(m)).toBe(true); - }); - - it("lists via the Lister capability", async () => { - const m = provideUploads({ bucketName: "test" }); - await saveFile(m, "a.txt", bytes(1)); - await saveFile(m, "b.txt", bytes(2)); - if (!isLister(m)) throw new Error("expected a Lister"); - const paths = (await listAll(m, "")).map((o) => o.path); - expect(paths.sort()).toEqual(["a.txt", "b.txt"]); - }); - - it("honors bucketPrefix transparently", async () => { - const m = provideUploads({ bucketName: "test", bucketPrefix: "p/" }); - await saveFile(m, "a.txt", bytes(1)); - if (!isLister(m)) throw new Error("expected a Lister"); - expect((await listAll(m, "")).map((o) => o.path)).toEqual(["a.txt"]); - }); -}); - -describe("NoopUploadManager", () => { - it("discards writes and reads back nothing", async () => { - const m: UploadManager = new NoopUploadManager(); - await m.save("a", bytes(1)); - await m.save("s", bytesToStream(bytes(1, 2))); - expect(await read(await m.open("a"))).toEqual(bytes()); - expect(await m.exists("a")).toBe(false); - await expect(m.delete("a")).resolves.toBeUndefined(); - }); - - it("lists nothing and rejects signing (a noop can't mint a real URL)", async () => { - const m: Attributer & Lister & URLSigner = new NoopUploadManager(); - expect(await listAll(m, "")).toEqual([]); - await expect(m.signedURL("a")).rejects.toBeInstanceOf(SigningUnsupportedError); - expect((await m.attributes("a")).size).toBe(0); - }); -}); - -describe("maxSizeBytes backstop", () => { - it("rejects a byte body over the limit before writing", async () => { - const m = provideUploads({ bucketName: "b", provider: "memory", maxSizeBytes: 4 }); - await expect(m.save("a", bytes(1, 2, 3, 4, 5))).rejects.toMatchObject({ - code: "uploads/file-too-large", - }); - // a body within the limit still writes. - await expect(m.save("a", bytes(1, 2, 3, 4))).resolves.toBeUndefined(); - }); - - it("rejects a stream body once it crosses the limit", async () => { - const m = provideUploads({ bucketName: "b", provider: "memory", maxSizeBytes: 4 }); - const oversized = bytesToStream(bytes(1, 2, 3, 4, 5, 6)); - await expect(m.save("s", oversized)).rejects.toMatchObject({ - code: "uploads/file-too-large", - }); - }); - - it("is disabled by default (maxSizeBytes 0)", async () => { - const m = provideUploads({ bucketName: "b", provider: "memory" }); - await expect(m.save("a", bytes(1, 2, 3, 4, 5, 6, 7, 8))).resolves.toBeUndefined(); - }); -}); - -describe("UploadsConfigSchema", () => { - it("defaults to the memory provider", () => { - expect(UploadsConfigSchema.parse({ bucketName: "b" }).provider).toBe("memory"); - }); - - it("requires bucketName", () => { - expect(() => UploadsConfigSchema.parse({})).toThrow(); - }); - - it("requires the filesystem sub-config for the filesystem provider", () => { - expect(() => - UploadsConfigSchema.parse({ bucketName: "b", provider: "filesystem" }), - ).toThrow(); - expect(() => - UploadsConfigSchema.parse({ - bucketName: "b", - provider: "filesystem", - filesystem: { rootDirectory: "/tmp/x" }, - }), - ).not.toThrow(); - }); - - it("requires r2 credentials for the r2 provider", () => { - expect(() => - UploadsConfigSchema.parse({ bucketName: "b", provider: "r2" }), - ).toThrow(); - }); -}); - -/** - * S3Bucket against a fake `send` that dispatches on command name over an in-memory map — exercises - * the S3/R2/Backblaze code path (range headers, list, not-found normalization) without a network. - */ -describe("S3Bucket (fake client)", () => { - class NotFound extends Error { - override name = "NoSuchKey"; - } - - function fakeClient(): S3Client { - const store = new Map(); - const send = (command: { - constructor: { name: string }; - input: Record; - }) => { - const { name } = command.constructor; - const key = command.input.Key as string; - switch (name) { - case "PutObjectCommand": - store.set(key, command.input.Body as Uint8Array); - return Promise.resolve({}); - case "GetObjectCommand": { - const value = store.get(key); - if (value === undefined) return Promise.reject(new NotFound()); - const range = command.input.Range as string | undefined; - let slice = value; - if (range) { - const [start, end] = range.replace("bytes=", "").split("-"); - slice = value.slice(Number(start), end === "" ? undefined : Number(end) + 1); - } - return Promise.resolve({ - Body: { transformToWebStream: () => bytesToStream(slice) }, - }); - } - case "HeadObjectCommand": { - const value = store.get(key); - if (value === undefined) return Promise.reject(new NotFound()); - return Promise.resolve({ - ContentLength: value.length, - ContentType: "text/plain", - }); - } - case "DeleteObjectCommand": - store.delete(key); - return Promise.resolve({}); - case "ListObjectsV2Command": { - const prefix = (command.input.Prefix as string | undefined) ?? ""; - const Contents = [...store.entries()] - .filter(([k]) => k.startsWith(prefix)) - .map(([k, v]) => ({ Key: k, Size: v.length })); - return Promise.resolve({ Contents, IsTruncated: false }); - } - default: - return Promise.reject(new Error(`unexpected command ${name}`)); - } - }; - return { send } as unknown as S3Client; - } - - it("round-trips, ranges, heads, lists, and deletes", async () => { - const b = new S3Bucket(fakeClient(), "bucket"); - await b.write("a.txt", bytes(0, 1, 2, 3, 4), { contentType: "text/plain" }); - - expect(await read(await b.openRange("a.txt", 0, -1))).toEqual(bytes(0, 1, 2, 3, 4)); - expect(await read(await b.openRange("a.txt", 1, 2))).toEqual(bytes(1, 2)); - expect(await read(await b.openRange("a.txt", 3, -1))).toEqual(bytes(3, 4)); - - expect(await b.exists("a.txt")).toBe(true); - expect(await b.exists("missing")).toBe(false); - expect((await b.attributes("a.txt")).size).toBe(5); - expect((await listAll(b, "")).map((o) => o.path)).toEqual(["a.txt"]); - - await b.delete("a.txt"); - expect(await b.exists("a.txt")).toBe(false); - }); - - it("normalizes a missing object to BlobNotFoundError", async () => { - const b = new S3Bucket(fakeClient(), "bucket"); - await expect(b.openRange("gone", 0, -1)).rejects.toBeInstanceOf(BlobNotFoundError); - await expect(b.attributes("gone")).rejects.toBeInstanceOf(BlobNotFoundError); - }); -}); diff --git a/packages/uploads/src/uploads.ts b/packages/uploads/src/uploads.ts deleted file mode 100644 index d487a47..0000000 --- a/packages/uploads/src/uploads.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { collectStream, type BlobBody } from "./stream.js"; - -/** - * Reads and writes objects in a storage provider — the faithful port of platform-go's - * `uploads.UploadManager`. - * - * The core surface is deliberately small: `save`/`open`/`delete`/`exists`. Richer backends also - * implement the optional capability interfaces in `capabilities.ts` (ranged reads, signed URLs, - * attributes, listing); callers reach for those by type-narrowing a manager (see `isLister` &c.). - * - * `open` rejects when a path is absent — a missing object is genuinely exceptional here, unlike - * the cache/secrets miss-as-`undefined` convention. Providers reject with {@link BlobNotFoundError}. - */ -export interface UploadManager { - /** - * Writes `body` to the object at `path`, replacing any existing object. `body` may be a byte - * slice or a {@link ReadableStream} for content that shouldn't be buffered in memory. - */ - save(path: string, body: BlobBody, opts?: SaveOptions): Promise; - /** Returns a byte stream for the object at `path`. Rejects when `path` is absent. */ - open(path: string): Promise>; - /** Removes the object at `path`. */ - delete(path: string): Promise; - /** Reports whether an object exists at `path`. */ - exists(path: string): Promise; -} - -/** - * Resolved settings for a {@link UploadManager.save} call — the port of Go's `SaveOptions` - * (its functional `WithContentType`/`WithCacheControl` options collapse to this plain object in - * idiomatic TypeScript). - */ -export interface SaveOptions { - /** - * The stored `Content-Type`. When omitted, providers that can sniff (e.g. object stores) - * infer it from the content on write; others store the object untyped. - */ - contentType?: string; - /** The stored `Cache-Control` header for served objects. */ - cacheControl?: string; -} - -/** - * Saves a byte slice via {@link UploadManager.save} — the port of Go's `uploads.SaveFile` - * convenience helper. - */ -export function saveFile( - m: UploadManager, - path: string, - content: Uint8Array, - opts?: SaveOptions, -): Promise { - return m.save(path, content, opts); -} - -/** - * Reads an entire object into memory via {@link UploadManager.open} — the port of Go's - * `uploads.ReadFile`. Rejects when the object is absent. - */ -export async function readFile(m: UploadManager, path: string): Promise { - return collectStream(await m.open(path)); -} diff --git a/packages/uploads/tsconfig.json b/packages/uploads/tsconfig.json deleted file mode 100644 index bdb9327..0000000 --- a/packages/uploads/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ES2022"], - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/uploads/tsup.config.ts b/packages/uploads/tsup.config.ts deleted file mode 100644 index 8bf2394..0000000 --- a/packages/uploads/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { index: "src/index.ts" }, - platform: "node", - format: ["esm"], - dts: true, - sourcemap: true, - treeshake: true, - clean: true, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a80cf61..6d0fba0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,30 +60,6 @@ importers: specifier: 3.25.76 version: 3.25.76 - packages/authentication: - dependencies: - '@primandproper/errors': - specifier: workspace:* - version: link:../errors - '@primandproper/observability': - specifier: workspace:* - version: link:../observability - zod: - specifier: 3.25.76 - version: 3.25.76 - - packages/authorization: - dependencies: - '@primandproper/errors': - specifier: workspace:* - version: link:../errors - '@primandproper/observability': - specifier: workspace:* - version: link:../observability - zod: - specifier: 3.25.76 - version: 3.25.76 - packages/bitmask: {} packages/cache: @@ -143,54 +119,6 @@ importers: specifier: 3.25.76 version: 3.25.76 - packages/database: - dependencies: - '@primandproper/errors': - specifier: workspace:* - version: link:../errors - '@primandproper/observability': - specifier: workspace:* - version: link:../observability - zod: - specifier: 3.25.76 - version: 3.25.76 - - packages/distributedlock: - dependencies: - '@primandproper/database': - specifier: workspace:* - version: link:../database - '@primandproper/errors': - specifier: workspace:* - version: link:../errors - '@primandproper/observability': - specifier: workspace:* - version: link:../observability - ioredis: - specifier: 5.11.1 - version: 5.11.1 - zod: - specifier: 3.25.76 - version: 3.25.76 - - packages/email: - dependencies: - '@primandproper/errors': - specifier: workspace:* - version: link:../errors - '@primandproper/observability': - specifier: workspace:* - version: link:../observability - '@primandproper/retry': - specifier: workspace:* - version: link:../retry - postmark: - specifier: 4.0.7 - version: 4.0.7 - zod: - specifier: 3.25.76 - version: 3.25.76 - packages/encoding: dependencies: '@primandproper/errors': @@ -286,12 +214,6 @@ importers: specifier: 3.25.76 version: 3.25.76 - packages/healthcheck: - dependencies: - '@primandproper/observability': - specifier: workspace:* - version: link:../observability - packages/httpclient: dependencies: '@opentelemetry/api': @@ -310,30 +232,6 @@ importers: specifier: 3.25.76 version: 3.25.76 - packages/idempotency: - dependencies: - '@primandproper/cache': - specifier: workspace:* - version: link:../cache - '@primandproper/cryptography': - specifier: workspace:* - version: link:../cryptography - '@primandproper/distributedlock': - specifier: workspace:* - version: link:../distributedlock - '@primandproper/errors': - specifier: workspace:* - version: link:../errors - '@primandproper/identifiers': - specifier: workspace:* - version: link:../identifiers - '@primandproper/observability': - specifier: workspace:* - version: link:../observability - zod: - specifier: 3.25.76 - version: 3.25.76 - packages/identifiers: dependencies: nanoid: @@ -346,66 +244,6 @@ importers: specifier: 3.25.76 version: 3.25.76 - packages/llm: - dependencies: - '@primandproper/observability': - specifier: workspace:* - version: link:../observability - '@primandproper/retry': - specifier: workspace:* - version: link:../retry - zod: - specifier: 3.25.76 - version: 3.25.76 - - packages/messagequeue: - dependencies: - '@aws-sdk/client-sqs': - specifier: 3.699.0 - version: 3.699.0 - '@google-cloud/pubsub': - specifier: 5.3.1 - version: 5.3.1 - '@primandproper/errors': - specifier: workspace:* - version: link:../errors - '@primandproper/observability': - specifier: workspace:* - version: link:../observability - ioredis: - specifier: 5.11.1 - version: 5.11.1 - kafkajs: - specifier: 2.2.4 - version: 2.2.4 - zod: - specifier: 3.25.76 - version: 3.25.76 - - packages/notifications: - dependencies: - '@parse/node-apn': - specifier: 8.1.0 - version: 8.1.0 - '@primandproper/errors': - specifier: workspace:* - version: link:../errors - '@primandproper/observability': - specifier: workspace:* - version: link:../observability - ably: - specifier: 2.23.0 - version: 2.23.0 - firebase-admin: - specifier: 14.1.0 - version: 14.1.0 - pusher: - specifier: 5.3.4 - version: 5.3.4 - zod: - specifier: 3.25.76 - version: 3.25.76 - packages/numbers: {} packages/observability: @@ -472,459 +310,128 @@ importers: specifier: 3.25.76 version: 3.25.76 - packages/search: - dependencies: - '@elastic/elasticsearch': - specifier: 8.19.1 - version: 8.19.1 - '@primandproper/circuitbreaking': - specifier: workspace:* - version: link:../circuitbreaking - '@primandproper/errors': - specifier: workspace:* - version: link:../errors - '@primandproper/observability': - specifier: workspace:* - version: link:../observability - algoliasearch: - specifier: 5.55.0 - version: 5.55.0 - typesense: - specifier: 3.0.6 - version: 3.0.6(@babel/runtime@7.29.7) - zod: - specifier: 3.25.76 - version: 3.25.76 - - packages/secrets: - dependencies: - '@aws-sdk/client-ssm': - specifier: 3.699.0 - version: 3.699.0 - '@google-cloud/secret-manager': - specifier: 6.2.0 - version: 6.2.0 - '@kubernetes/client-node': - specifier: 1.4.0 - version: 1.4.0 - '@primandproper/cache': - specifier: workspace:* - version: link:../cache - '@primandproper/errors': - specifier: workspace:* - version: link:../errors - '@primandproper/observability': - specifier: workspace:* - version: link:../observability - zod: - specifier: 3.25.76 - version: 3.25.76 - - packages/uploads: - dependencies: - '@aws-sdk/client-s3': - specifier: 3.700.0 - version: 3.700.0 - '@aws-sdk/lib-storage': - specifier: 3.700.0 - version: 3.700.0(@aws-sdk/client-s3@3.700.0) - '@aws-sdk/s3-request-presigner': - specifier: 3.700.0 - version: 3.700.0 - '@google-cloud/storage': - specifier: 7.21.0 - version: 7.21.0 - '@primandproper/circuitbreaking': - specifier: workspace:* - version: link:../circuitbreaking - '@primandproper/errors': - specifier: workspace:* - version: link:../errors - '@primandproper/observability': - specifier: workspace:* - version: link:../observability - zod: - specifier: 3.25.76 - version: 3.25.76 - packages/version: {} packages: - '@ably/msgpack-js@0.4.1': - resolution: {integrity: sha512-Sjxj6SOr17hExAVrsycN7u6oV4PhZcK7Z2S8dM71CH/butgO47cSo/TL6FJPCXUyDAzKkOWjMUpJGyZkEpyu4Q==} - - '@algolia/abtesting@1.21.0': - resolution: {integrity: sha512-kGvHfBa9oQCvZh0YXeguSToBD9GNJ+gzUZQ9KPTg+KSsM36obYcsKPoX0NnlJtPflHXu7RkMaIi44xs9meR6Zw==} - engines: {node: '>= 14.0.0'} - - '@algolia/client-abtesting@5.55.0': - resolution: {integrity: sha512-Zt2GjIm7vsaf7K23tk5JmtcVNc38G9p0C2L2Lrm06miyLE/NL2etHtHInvuLc1DjxTp7Y2nId4X/tzwo372K8Q==} - engines: {node: '>= 14.0.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} - '@algolia/client-analytics@5.55.0': - resolution: {integrity: sha512-7BueMuWYg/KBA2EX9zsQ+3OAleEyrJcB+SV5Al/9pLjMQq5mXB/8M5HaUPqZwN812g5kLzj9j43VThlZgWq0hg==} - engines: {node: '>= 14.0.0'} + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} - '@algolia/client-common@5.55.0': - resolution: {integrity: sha512-pJZIyhvUrs+B7c5Lw0iP5yP/NsqJMda7pKRYbfG4KtfGIVSMcAalZhdqL5UX8Z9DOC4KxO9tKV5RDeVjZU0VfQ==} - engines: {node: '>= 14.0.0'} + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} - '@algolia/client-insights@5.55.0': - resolution: {integrity: sha512-RydkKDhx0GWTYuw0ndTXHGM8hD8hgwftKE65FfnJZb5bPc9CevOqv3qNPUQiviAwkqT9hQNH31uDGeV3yZkgfA==} - engines: {node: '>= 14.0.0'} + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} - '@algolia/client-personalization@5.55.0': - resolution: {integrity: sha512-XiS7gdFq/COWiwdWXZ8+RHuewfEo03TkGESk44zU8zTc/Z6R8fm4DNmV52swJKkeB2N9iC7NKpgpM22OOkcgTw==} - engines: {node: '>= 14.0.0'} + '@changesets/cli@2.31.0': + resolution: {integrity: sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==} + hasBin: true - '@algolia/client-query-suggestions@5.55.0': - resolution: {integrity: sha512-LBEJ/q+hn1nJ0aYg5IcWgLNCPjWHTahWmpHNx1qUZMho+9CyWM6LaEnhac45UHjQm/j0m374HP685VrpL133lA==} - engines: {node: '>= 14.0.0'} + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} - '@algolia/client-search@5.55.0': - resolution: {integrity: sha512-2/9jUXKH4IcdU5qxH6cbDH46ZBe46G7xr+MrcHwgEXZcUfdAvUgLSH53MAWuMgxvw0G5yoqiWMifHc62Os0fiQ==} - engines: {node: '>= 14.0.0'} + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} - '@algolia/ingestion@1.55.0': - resolution: {integrity: sha512-80tKsQgxXWo+jK0v4YGCHqyTEXawhAKYyr3kOdN51ElfRqUFjZNPVhZk6vRiqSqXfvrH85ytacT3cbJR6+qolA==} - engines: {node: '>= 14.0.0'} + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} - '@algolia/monitoring@1.55.0': - resolution: {integrity: sha512-4UjmAL8ywGW4rCfK6Qmgw3wIjbrO2wl2s4Eq56JTiN40L2t0XTv0HZkYAmr6nfeiXO0he/2crvZRX6SATSepag==} - engines: {node: '>= 14.0.0'} + '@changesets/get-release-plan@4.0.16': + resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} - '@algolia/recommend@5.55.0': - resolution: {integrity: sha512-LMpJPtIkfDsHIx5Ga+baNr22ntYbY+e2wT7MSIc/FjAnu9wnBFhx1H/GfhmP/c5/IvbThDX+3ilxPRjSfCI8aA==} - engines: {node: '>= 14.0.0'} + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} - '@algolia/requester-browser-xhr@5.55.0': - resolution: {integrity: sha512-tDymJ7nFOAoUuecma3usK6o94dp8m4HYFDGh4ByYQXWkv14cpmDn+nWdylmcZO0Qvco107vqDo4+Anksnl8w1Q==} - engines: {node: '>= 14.0.0'} + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} - '@algolia/requester-fetch@5.55.0': - resolution: {integrity: sha512-6IDSB5o5dkDPQ4LdOW0Yuw/qy5MdWlO2xDHgPVZgW4YDjbxvnX5PAiV7/WWZdWyVObScZZnnHpPbiqfYs/zBLg==} - engines: {node: '>= 14.0.0'} + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} - '@algolia/requester-node-http@5.55.0': - resolution: {integrity: sha512-Yyyne4l//vDSdg4MhYJkaVne+KEPi833eCj3/T/87ernTwrvP6j9biXXZELsN8sLI/f2ndV/vugDIy2jdJQB6g==} - engines: {node: '>= 14.0.0'} + '@changesets/parse@0.4.3': + resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} - '@aws-crypto/crc32@5.2.0': - resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} - engines: {node: '>=16.0.0'} + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} - '@aws-crypto/crc32c@5.2.0': - resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + '@changesets/read@0.6.7': + resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} - '@aws-crypto/sha1-browser@5.2.0': - resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} - '@aws-crypto/sha256-browser@5.2.0': - resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} - '@aws-crypto/sha256-js@5.2.0': - resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} - engines: {node: '>=16.0.0'} + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} - '@aws-crypto/supports-web-crypto@5.2.0': - resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@aws-crypto/util@5.2.0': - resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] - '@aws-sdk/client-s3@3.700.0': - resolution: {integrity: sha512-TZTc8OZ873VodJNcsQ4Y/60f0Y0Ws5Z3Xm5QBgPCvhqQS7/+V28pXM3fAomJsKDWTxzH0nP9csX5cPvLybgdZQ==} - engines: {node: '>=16.0.0'} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] - '@aws-sdk/client-sqs@3.699.0': - resolution: {integrity: sha512-St0zhtmwTer0+WCgPgiwVx6uuQnzL40PpYBcC+tE6z3atk9ZmNTXivMuQpDizoGYvVGRhq5YUDwsncbr7S96Aw==} - engines: {node: '>=16.0.0'} + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] - '@aws-sdk/client-ssm@3.699.0': - resolution: {integrity: sha512-ROynEZI8RZC+NkQP/BBcdkhGHuk+RJkinemxmsTt8FGYqHlK/7hcOjWitziAGpmjWfPm4a6UAtqeNg/AX7nvlw==} - engines: {node: '>=16.0.0'} + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] - '@aws-sdk/client-sso-oidc@3.699.0': - resolution: {integrity: sha512-u8a1GorY5D1l+4FQAf4XBUC1T10/t7neuwT21r0ymrtMFSK2a9QqVHKMoLkvavAwyhJnARSBM9/UQC797PFOFw==} - engines: {node: '>=16.0.0'} - peerDependencies: - '@aws-sdk/client-sts': ^3.699.0 + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] - '@aws-sdk/client-sso@3.696.0': - resolution: {integrity: sha512-q5TTkd08JS0DOkHfUL853tuArf7NrPeqoS5UOvqJho8ibV9Ak/a/HO4kNvy9Nj3cib/toHYHsQIEtecUPSUUrQ==} - engines: {node: '>=16.0.0'} + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] - '@aws-sdk/client-sts@3.699.0': - resolution: {integrity: sha512-++lsn4x2YXsZPIzFVwv3fSUVM55ZT0WRFmPeNilYIhZClxHLmVAWKH4I55cY9ry60/aTKYjzOXkWwyBKGsGvQg==} - engines: {node: '>=16.0.0'} + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] - '@aws-sdk/core@3.696.0': - resolution: {integrity: sha512-3c9III1k03DgvRZWg8vhVmfIXPG6hAciN9MzQTzqGngzWAELZF/WONRTRQuDFixVtarQatmLHYVw/atGeA2Byw==} - engines: {node: '>=16.0.0'} + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] - '@aws-sdk/credential-provider-env@3.696.0': - resolution: {integrity: sha512-T9iMFnJL7YTlESLpVFT3fg1Lkb1lD+oiaIC8KMpepb01gDUBIpj9+Y+pA/cgRWW0yRxmkDXNazAE2qQTVFGJzA==} - engines: {node: '>=16.0.0'} + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] - '@aws-sdk/credential-provider-http@3.696.0': - resolution: {integrity: sha512-GV6EbvPi2eq1+WgY/o2RFA3P7HGmnkIzCNmhwtALFlqMroLYWKE7PSeHw66Uh1dFQeVESn0/+hiUNhu1mB0emA==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/credential-provider-ini@3.699.0': - resolution: {integrity: sha512-dXmCqjJnKmG37Q+nLjPVu22mNkrGHY8hYoOt3Jo9R2zr5MYV7s/NHsCHr+7E+BZ+tfZYLRPeB1wkpTeHiEcdRw==} - engines: {node: '>=16.0.0'} - peerDependencies: - '@aws-sdk/client-sts': ^3.699.0 - - '@aws-sdk/credential-provider-node@3.699.0': - resolution: {integrity: sha512-MmEmNDo1bBtTgRmdNfdQksXu4uXe66s0p1hi1YPrn1h59Q605eq/xiWbGL6/3KdkViH6eGUuABeV2ODld86ylg==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/credential-provider-process@3.696.0': - resolution: {integrity: sha512-mL1RcFDe9sfmyU5K1nuFkO8UiJXXxLX4JO1gVaDIOvPqwStpUAwi3A1BoeZhWZZNQsiKI810RnYGo0E0WB/hUA==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/credential-provider-sso@3.699.0': - resolution: {integrity: sha512-Ekp2cZG4pl9D8+uKWm4qO1xcm8/MeiI8f+dnlZm8aQzizeC+aXYy9GyoclSf6daK8KfRPiRfM7ZHBBL5dAfdMA==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/credential-provider-web-identity@3.696.0': - resolution: {integrity: sha512-XJ/CVlWChM0VCoc259vWguFUjJDn/QwDqHwbx+K9cg3v6yrqXfK5ai+p/6lx0nQpnk4JzPVeYYxWRpaTsGC9rg==} - engines: {node: '>=16.0.0'} - peerDependencies: - '@aws-sdk/client-sts': ^3.696.0 - - '@aws-sdk/lib-storage@3.700.0': - resolution: {integrity: sha512-bcPaWwihmfaVSJnrKb6WnpL2f7TTKAVbIwUUqXpau6/A7WonCI1oiYswCAmMQFJ3ETRVWz/3f8wiAoBKitTTyQ==} - engines: {node: '>=16.0.0'} - peerDependencies: - '@aws-sdk/client-s3': ^3.700.0 - - '@aws-sdk/middleware-bucket-endpoint@3.696.0': - resolution: {integrity: sha512-V07jishKHUS5heRNGFpCWCSTjRJyQLynS/ncUeE8ZYtG66StOOQWftTwDfFOSoXlIqrXgb4oT9atryzXq7Z4LQ==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/middleware-expect-continue@3.696.0': - resolution: {integrity: sha512-vpVukqY3U2pb+ULeX0shs6L0aadNep6kKzjme/MyulPjtUDJpD3AekHsXRrCCGLmOqSKqRgQn5zhV9pQhHsb6Q==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/middleware-flexible-checksums@3.697.0': - resolution: {integrity: sha512-K/y43P+NuHu5+21/29BoJSltcPekvcCU8i74KlGGHbW2Z105e5QVZlFjxivcPOjOA3gdC0W4SoFSIWam5RBhzw==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/middleware-host-header@3.696.0': - resolution: {integrity: sha512-zELJp9Ta2zkX7ELggMN9qMCgekqZhFC5V2rOr4hJDEb/Tte7gpfKSObAnw/3AYiVqt36sjHKfdkoTsuwGdEoDg==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/middleware-location-constraint@3.696.0': - resolution: {integrity: sha512-FgH12OB0q+DtTrP2aiDBddDKwL4BPOrm7w3VV9BJrSdkqQCNBPz8S1lb0y5eVH4tBG+2j7gKPlOv1wde4jF/iw==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/middleware-logger@3.696.0': - resolution: {integrity: sha512-KhkHt+8AjCxcR/5Zp3++YPJPpFQzxpr+jmONiT/Jw2yqnSngZ0Yspm5wGoRx2hS1HJbyZNuaOWEGuJoxLeBKfA==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/middleware-recursion-detection@3.696.0': - resolution: {integrity: sha512-si/maV3Z0hH7qa99f9ru2xpS5HlfSVcasRlNUXKSDm611i7jFMWwGNLUOXFAOLhXotPX5G3Z6BLwL34oDeBMug==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/middleware-sdk-s3@3.696.0': - resolution: {integrity: sha512-M7fEiAiN7DBMHflzOFzh1I2MNSlLpbiH2ubs87bdRc2wZsDPSbs4l3v6h3WLhxoQK0bq6vcfroudrLBgvCuX3Q==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/middleware-sdk-sqs@3.696.0': - resolution: {integrity: sha512-wQl4v5DjI9G/YWflxhSiqgtYnnOIuI5U85IvPc13A3QZH6CUgifM+10Fj1ThOSVv/KKZQCvLxney/nbjMf9naQ==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/middleware-ssec@3.696.0': - resolution: {integrity: sha512-w/d6O7AOZ7Pg3w2d3BxnX5RmGNWb5X4RNxF19rJqcgu/xqxxE/QwZTNd5a7eTsqLXAUIfbbR8hh0czVfC1pJLA==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/middleware-user-agent@3.696.0': - resolution: {integrity: sha512-Lvyj8CTyxrHI6GHd2YVZKIRI5Fmnugt3cpJo0VrKKEgK5zMySwEZ1n4dqPK6czYRWKd5+WnYHYAuU+Wdk6Jsjw==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/region-config-resolver@3.696.0': - resolution: {integrity: sha512-7EuH142lBXjI8yH6dVS/CZeiK/WZsmb/8zP6bQbVYpMrppSTgB3MzZZdxVZGzL5r8zPQOU10wLC4kIMy0qdBVQ==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/s3-request-presigner@3.700.0': - resolution: {integrity: sha512-1M84DaDsRuRZHC9tKyRNAGXUNg7/WeHgizpHVVeDo62s83E4I+mHHOf4Cmzui+KxbXEVkcnjJyIiXqbCQJY43w==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/signature-v4-multi-region@3.696.0': - resolution: {integrity: sha512-ijPkoLjXuPtgxAYlDoYls8UaG/VKigROn9ebbvPL/orEY5umedd3iZTcS9T+uAf4Ur3GELLxMQiERZpfDKaz3g==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/token-providers@3.699.0': - resolution: {integrity: sha512-kuiEW9DWs7fNos/SM+y58HCPhcIzm1nEZLhe2/7/6+TvAYLuEWURYsbK48gzsxXlaJ2k/jGY3nIsA7RptbMOwA==} - engines: {node: '>=16.0.0'} - peerDependencies: - '@aws-sdk/client-sso-oidc': ^3.699.0 - - '@aws-sdk/types@3.696.0': - resolution: {integrity: sha512-9rTvUJIAj5d3//U5FDPWGJ1nFJLuWb30vugGOrWk7aNZ6y9tuA3PI7Cc9dP8WEXKVyK1vuuk8rSFP2iqXnlgrw==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/util-arn-parser@3.693.0': - resolution: {integrity: sha512-WC8x6ca+NRrtpAH64rWu+ryDZI3HuLwlEr8EU6/dbC/pt+r/zC0PBoC15VEygUaBA+isppCikQpGyEDu0Yj7gQ==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/util-endpoints@3.696.0': - resolution: {integrity: sha512-T5s0IlBVX+gkb9g/I6CLt4yAZVzMSiGnbUqWihWsHvQR1WOoIcndQy/Oz/IJXT9T2ipoy7a80gzV6a5mglrioA==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/util-format-url@3.696.0': - resolution: {integrity: sha512-R6yK1LozUD1GdAZRPhNsIow6VNFJUTyyoIar1OCWaknlucBMcq7musF3DN3TlORBwfFMj5buHc2ET9OtMtzvuA==} - engines: {node: '>=16.0.0'} - - '@aws-sdk/util-locate-window@3.965.8': - resolution: {integrity: sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-user-agent-browser@3.696.0': - resolution: {integrity: sha512-Z5rVNDdmPOe6ELoM5AhF/ja5tSjbe6ctSctDPb0JdDf4dT0v2MfwhJKzXju2RzX8Es/77Glh7MlaXLE0kCB9+Q==} - - '@aws-sdk/util-user-agent-node@3.696.0': - resolution: {integrity: sha512-KhKqcfyXIB0SCCt+qsu4eJjsfiOrNzK5dCV7RAW2YIpp+msxGUUX0NdRE9rkzjiv+3EMktgJm3eEIS+yxtlVdQ==} - engines: {node: '>=16.0.0'} - peerDependencies: - aws-crt: '>=1.0.0' - peerDependenciesMeta: - aws-crt: - optional: true - - '@aws-sdk/xml-builder@3.696.0': - resolution: {integrity: sha512-dn1mX+EeqivoLYnY7p2qLrir0waPnCgS/0YdRCAVU2x14FgfUYCH6Im3w3oi2dMwhxfKY5lYVB5NKvZu7uI9lQ==} - engines: {node: '>=16.0.0'} - - '@babel/runtime@7.29.7': - resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} - engines: {node: '>=6.9.0'} - - '@changesets/apply-release-plan@7.1.1': - resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} - - '@changesets/assemble-release-plan@6.0.10': - resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} - - '@changesets/changelog-git@0.2.1': - resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} - - '@changesets/cli@2.31.0': - resolution: {integrity: sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==} - hasBin: true - - '@changesets/config@3.1.4': - resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} - - '@changesets/errors@0.2.0': - resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} - - '@changesets/get-dependents-graph@2.1.4': - resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} - - '@changesets/get-release-plan@4.0.16': - resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} - - '@changesets/get-version-range-type@0.4.0': - resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} - - '@changesets/git@3.0.4': - resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} - - '@changesets/logger@0.1.1': - resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} - - '@changesets/parse@0.4.3': - resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} - - '@changesets/pre@2.0.2': - resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} - - '@changesets/read@0.6.7': - resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} - - '@changesets/should-skip-package@0.1.2': - resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} - - '@changesets/types@4.1.0': - resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} - - '@changesets/types@6.1.0': - resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} - - '@changesets/write@0.4.0': - resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - - '@elastic/elasticsearch@8.19.1': - resolution: {integrity: sha512-+1j9NnQVOX+lbWB8LhCM7IkUmjU05Y4+BmSLfusq0msCsQb1Va+OUKFCoOXjCJqQrcgdRdQCjYYyolQ/npQALQ==} - engines: {node: '>=18'} - - '@elastic/transport@8.10.1': - resolution: {integrity: sha512-xo2lPBAJEt81fQRAKa9T/gUq1SPGBHpSnVUXhoSpL996fPZRAfQwFA4BZtEUQL1p8Dezodd3ZN8Wwno+mYyKuw==} - engines: {node: '>=18'} - - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} @@ -1202,94 +709,6 @@ packages: resolution: {integrity: sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} - '@fastify/busboy@3.2.0': - resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} - - '@firebase/app-check-interop-types@0.3.4': - resolution: {integrity: sha512-zz3i6e13B8BfWiLy8MABtTh8aGIACgKbf9UVnyHcWs+yQzJXgQcl8A46b0zfaiJHdQ+niF0ouAfcpuf+3LMPQg==} - - '@firebase/app-types@0.9.5': - resolution: {integrity: sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==} - - '@firebase/auth-interop-types@0.2.5': - resolution: {integrity: sha512-1Li/YuBDBAXcKv7BzY4U28gontUmAaw53sYiqbaVOMCFb2lFKK/c3CGMUWqtwe7+TXrl3poWnTCL5umYBg85Eg==} - - '@firebase/component@0.7.3': - resolution: {integrity: sha512-wFofIaa2879ogD/WvkjYXJxRmfnL0scen6ORgaC3na1FNOR9ASIUANQdhqQcmWu/h77/pVHY7ch5flewa5Bcew==} - engines: {node: '>=20.0.0'} - - '@firebase/database-compat@2.1.4': - resolution: {integrity: sha512-3pK35F1MAgmqFJQlf2nhQl44vtAXQO1uaCaQOEUI9kCRtLFqi7N+QRKR7lFZPg+xIZIyubgxQaxY69YgfZRZWg==} - engines: {node: '>=20.0.0'} - - '@firebase/database-types@1.0.20': - resolution: {integrity: sha512-kegbOk/w8iU64pr0q6k2ItyNGjnQBMHFhwS7ohdWI4W+pc0/zhhdGXTdFj6X1oxItRjPoYOsSQmERgBkn/ihxw==} - - '@firebase/database@1.1.3': - resolution: {integrity: sha512-XwWCa+E4TvNGpGwXrycLRNfdogADwFcvuhyow6wDWma9W54roaQIhe+4PM0KiLsIftBdSCGI7OKCXrdSRHbIhw==} - engines: {node: '>=20.0.0'} - - '@firebase/logger@0.5.1': - resolution: {integrity: sha512-vZKLsqE1ABOy8OjQiE7cUTFn4gvaqlk88yp8N94Pk/sDpq61YqZGqmVFZTvOyflTwuYFcWirBdYGoJgbDaXKYQ==} - engines: {node: '>=20.0.0'} - - '@firebase/util@1.15.1': - resolution: {integrity: sha512-LUdM4Wg7YM9Pq/49nGYySJA0CSQEKnGffFzWV8+6gXN7mGxn+FL1IqvFbuZUtAQcfZgHYDwCE1wwlK7rB7gl2g==} - engines: {node: '>=20.0.0'} - - '@google-cloud/firestore@8.6.0': - resolution: {integrity: sha512-TdvZHfwQj5B5CSDEgDqyrhdVqtOSupmBXDQPasMAJiC64tjsGvyMooNiC43fdk1TsUHeklyoZ6/vQ1TjWKVMbg==} - engines: {node: '>=18'} - - '@google-cloud/paginator@5.0.2': - resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==} - engines: {node: '>=14.0.0'} - - '@google-cloud/paginator@6.0.2': - resolution: {integrity: sha512-S60tHt4oHsVvviXY5+Ti5Reg69y5WjBglwRjJqBiVdRHxniW/L+Mc3mp7yNMcxr2Gi6DkDO4aIognNWcUxf3Vg==} - engines: {node: '>=18'} - - '@google-cloud/precise-date@5.0.1': - resolution: {integrity: sha512-9HlRbOcDb8b2tSsOvljPD/Rm+Jn9KxMVB6sLf85CBnoIYdCFTNO1FIizQ13P75itXpSXsLuMlg1XK5opHKVzjg==} - engines: {node: '>=18'} - - '@google-cloud/projectify@4.0.0': - resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==} - engines: {node: '>=14.0.0'} - - '@google-cloud/projectify@5.0.1': - resolution: {integrity: sha512-yAIqOAlDwx1nPmmA0WICFMSGttVuWX97HpphN71JQ7G+tD/Q6lHbiCTcMJ9r2+PwX6sIwK3ahO4FdSNt5pwhFg==} - engines: {node: '>=18'} - - '@google-cloud/promisify@4.0.0': - resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==} - engines: {node: '>=14'} - - '@google-cloud/promisify@5.0.1': - resolution: {integrity: sha512-Ste6NGraHq30ge3Sdq7m+pZE6lRUlkt2YgsEdq/vcoEgdbdZ/7h37Z1dMvivjhlbgrcmYRvlLF9FgI7tXm5wjg==} - engines: {node: '>=18'} - - '@google-cloud/pubsub@5.3.1': - resolution: {integrity: sha512-xXAQBVVrFviWz8L8yDKEqv1ziVw/gMSpYHt3IxSYRSY0fTyjis+xOrKJILj8dd8PAUHGnGiAIYuTprflkE9jsQ==} - engines: {node: '>=18'} - - '@google-cloud/secret-manager@6.2.0': - resolution: {integrity: sha512-KjusvEON/HLeS7VelJ1aoY4zUOcNnEYCQ69Vnncf3Fnk5OLl6Dl0QKimgw4ajW3CgR7pCUkNPneiK5HgWSGQOw==} - engines: {node: '>=18'} - - '@google-cloud/storage@7.21.0': - resolution: {integrity: sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==} - engines: {node: '>=14'} - - '@grpc/grpc-js@1.14.4': - resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} - engines: {node: '>=12.10.0'} - - '@grpc/proto-loader@0.8.1': - resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} - engines: {node: '>=6'} - hasBin: true - '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1322,10 +741,6 @@ packages: '@ioredis/commands@1.10.0': resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1339,24 +754,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@js-sdsl/ordered-map@4.4.2': - resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} - - '@jsep-plugin/assignment@1.3.0': - resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==} - engines: {node: '>= 10.16.0'} - peerDependencies: - jsep: ^0.4.0||^1.0.0 - - '@jsep-plugin/regex@1.0.4': - resolution: {integrity: sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==} - engines: {node: '>= 10.16.0'} - peerDependencies: - jsep: ^0.4.0||^1.0.0 - - '@kubernetes/client-node@1.4.0': - resolution: {integrity: sha512-Zge3YvF7DJi264dU1b3wb/GmzR99JhUpqTvp+VGHfwZT+g7EOOYNScDJNZwXy9cszyIGPIs0VHr+kk8e95qqrA==} - '@launchdarkly/js-sdk-common@2.25.1': resolution: {integrity: sha512-erG2RbA8QQMKW+D9Y7Uahez1dU+TzmyTzv9qlB7b0Pr/DS2SmUg5H8pJbkLcRrSxoshczZdB1UqsuE3RAk7UYQ==} @@ -1419,74 +816,15 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} - '@opentelemetry/core@1.30.1': - resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/core@2.8.0': - resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - - '@opentelemetry/semantic-conventions@1.28.0': - resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} - engines: {node: '>=14'} - - '@opentelemetry/semantic-conventions@1.39.0': - resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} - engines: {node: '>=14'} - - '@opentelemetry/semantic-conventions@1.41.1': - resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} - engines: {node: '>=14'} - - '@parse/node-apn@8.1.0': - resolution: {integrity: sha512-LowcdkKPDikbbzIr3zwEdoFW5wfEbbTzpYQeen3S8kKLePG0AQK586Li+OLC7bonyLmlk//Yk3smHZOLSV6TZA==} - engines: {node: 20 || 22 || 24} - '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - '@posthog/core@1.37.3': resolution: {integrity: sha512-Dvw4CTlRVH4K5ag/B8YIHgG4E27w43MCUsXpn1iKQHIggIMN3Shq9whG4Omm3OvXPF+VikBTmpyMKUjckPxw+g==} '@posthog/types@1.391.1': resolution: {integrity: sha512-ASwd7Nf4pViqdYRYaNRyPYRVKWa1CcHUAUWR0XeQJLGdNnsWACBwe0sSieb/cHnKsRXjRwO/23KIY83lm/Ccpw==} - '@protobufjs/aspromise@1.1.2': - resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} - - '@protobufjs/base64@1.1.2': - resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} - - '@protobufjs/codegen@2.0.5': - resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} - - '@protobufjs/eventemitter@1.1.1': - resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} - - '@protobufjs/fetch@1.1.1': - resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} - - '@protobufjs/float@1.0.2': - resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - - '@protobufjs/path@1.1.2': - resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} - - '@protobufjs/pool@1.1.0': - resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} - - '@protobufjs/utf8@1.1.1': - resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} - '@rollup/rollup-android-arm-eabi@4.62.0': resolution: {integrity: sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==} cpu: [arm] @@ -1656,230 +994,12 @@ packages: '@segment/isodate@1.0.3': resolution: {integrity: sha512-BtanDuvJqnACFkeeYje7pWULVv8RgZaqKHWwGFnL/g/TH/CcZjkIVTfGDp/MAxmilYHUkrX70SqwnYSTNEaN7A==} - '@sindresorhus/is@4.6.0': - resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} - engines: {node: '>=10'} - - '@smithy/abort-controller@3.1.9': - resolution: {integrity: sha512-yiW0WI30zj8ZKoSYNx90no7ugVn3khlyH/z5W8qtKBtVE6awRALbhSG+2SAHA1r6bO/6M9utxYKVZ3PCJ1rWxw==} - engines: {node: '>=16.0.0'} - - '@smithy/chunked-blob-reader-native@3.0.1': - resolution: {integrity: sha512-VEYtPvh5rs/xlyqpm5NRnfYLZn+q0SRPELbvBV+C/G7IQ+ouTuo+NKKa3ShG5OaFR8NYVMXls9hPYLTvIKKDrQ==} - - '@smithy/chunked-blob-reader@4.0.0': - resolution: {integrity: sha512-jSqRnZvkT4egkq/7b6/QRCNXmmYVcHwnJldqJ3IhVpQE2atObVJ137xmGeuGFhjFUr8gCEVAOKwSY79OvpbDaQ==} - - '@smithy/config-resolver@3.0.13': - resolution: {integrity: sha512-Gr/qwzyPaTL1tZcq8WQyHhTZREER5R1Wytmz4WnVGL4onA3dNk6Btll55c8Vr58pLdvWZmtG8oZxJTw3t3q7Jg==} - engines: {node: '>=16.0.0'} - - '@smithy/core@2.5.7': - resolution: {integrity: sha512-8olpW6mKCa0v+ibCjoCzgZHQx1SQmZuW/WkrdZo73wiTprTH6qhmskT60QLFdT9DRa5mXxjz89kQPZ7ZSsoqqg==} - engines: {node: '>=16.0.0'} - - '@smithy/credential-provider-imds@3.2.8': - resolution: {integrity: sha512-ZCY2yD0BY+K9iMXkkbnjo+08T2h8/34oHd0Jmh6BZUSZwaaGlGCyBT/3wnS7u7Xl33/EEfN4B6nQr3Gx5bYxgw==} - engines: {node: '>=16.0.0'} - - '@smithy/eventstream-codec@3.1.10': - resolution: {integrity: sha512-323B8YckSbUH0nMIpXn7HZsAVKHYHFUODa8gG9cHo0ySvA1fr5iWaNT+iIL0UCqUzG6QPHA3BSsBtRQou4mMqQ==} - - '@smithy/eventstream-serde-browser@3.0.14': - resolution: {integrity: sha512-kbrt0vjOIihW3V7Cqj1SXQvAI5BR8SnyQYsandva0AOR307cXAc+IhPngxIPslxTLfxwDpNu0HzCAq6g42kCPg==} - engines: {node: '>=16.0.0'} - - '@smithy/eventstream-serde-config-resolver@3.0.11': - resolution: {integrity: sha512-P2pnEp4n75O+QHjyO7cbw/vsw5l93K/8EWyjNCAAybYwUmj3M+hjSQZ9P5TVdUgEG08ueMAP5R4FkuSkElZ5tQ==} - engines: {node: '>=16.0.0'} - - '@smithy/eventstream-serde-node@3.0.13': - resolution: {integrity: sha512-zqy/9iwbj8Wysmvi7Lq7XFLeDgjRpTbCfwBhJa8WbrylTAHiAu6oQTwdY7iu2lxigbc9YYr9vPv5SzYny5tCXQ==} - engines: {node: '>=16.0.0'} - - '@smithy/eventstream-serde-universal@3.0.13': - resolution: {integrity: sha512-L1Ib66+gg9uTnqp/18Gz4MDpJPKRE44geOjOQ2SVc0eiaO5l255ADziATZgjQjqumC7yPtp1XnjHlF1srcwjKw==} - engines: {node: '>=16.0.0'} - - '@smithy/fetch-http-handler@4.1.3': - resolution: {integrity: sha512-6SxNltSncI8s689nvnzZQc/dPXcpHQ34KUj6gR/HBroytKOd/isMG3gJF/zBE1TBmTT18TXyzhg3O3SOOqGEhA==} - - '@smithy/hash-blob-browser@3.1.10': - resolution: {integrity: sha512-elwslXOoNunmfS0fh55jHggyhccobFkexLYC1ZeZ1xP2BTSrcIBaHV2b4xUQOdctrSNOpMqOZH1r2XzWTEhyfA==} - - '@smithy/hash-node@3.0.11': - resolution: {integrity: sha512-emP23rwYyZhQBvklqTtwetkQlqbNYirDiEEwXl2v0GYWMnCzxst7ZaRAnWuy28njp5kAH54lvkdG37MblZzaHA==} - engines: {node: '>=16.0.0'} - - '@smithy/hash-stream-node@3.1.10': - resolution: {integrity: sha512-olomK/jZQ93OMayW1zfTHwcbwBdhcZOHsyWyiZ9h9IXvc1mCD/VuvzbLb3Gy/qNJwI4MANPLctTp2BucV2oU/Q==} - engines: {node: '>=16.0.0'} - - '@smithy/invalid-dependency@3.0.11': - resolution: {integrity: sha512-NuQmVPEJjUX6c+UELyVz8kUx8Q539EDeNwbRyu4IIF8MeV7hUtq1FB3SHVyki2u++5XLMFqngeMKk7ccspnNyQ==} - - '@smithy/is-array-buffer@2.2.0': - resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} - engines: {node: '>=14.0.0'} - - '@smithy/is-array-buffer@3.0.0': - resolution: {integrity: sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==} - engines: {node: '>=16.0.0'} - - '@smithy/md5-js@3.0.11': - resolution: {integrity: sha512-3NM0L3i2Zm4bbgG6Ymi9NBcxXhryi3uE8fIfHJZIOfZVxOkGdjdgjR9A06SFIZCfnEIWKXZdm6Yq5/aPXFFhsQ==} - - '@smithy/middleware-content-length@3.0.13': - resolution: {integrity: sha512-zfMhzojhFpIX3P5ug7jxTjfUcIPcGjcQYzB9t+rv0g1TX7B0QdwONW+ATouaLoD7h7LOw/ZlXfkq4xJ/g2TrIw==} - engines: {node: '>=16.0.0'} - - '@smithy/middleware-endpoint@3.2.8': - resolution: {integrity: sha512-OEJZKVUEhMOqMs3ktrTWp7UvvluMJEvD5XgQwRePSbDg1VvBaL8pX8mwPltFn6wk1GySbcVwwyldL8S+iqnrEQ==} - engines: {node: '>=16.0.0'} - - '@smithy/middleware-retry@3.0.34': - resolution: {integrity: sha512-yVRr/AAtPZlUvwEkrq7S3x7Z8/xCd97m2hLDaqdz6ucP2RKHsBjEqaUA2ebNv2SsZoPEi+ZD0dZbOB1u37tGCA==} - engines: {node: '>=16.0.0'} - - '@smithy/middleware-serde@3.0.11': - resolution: {integrity: sha512-KzPAeySp/fOoQA82TpnwItvX8BBURecpx6ZMu75EZDkAcnPtO6vf7q4aH5QHs/F1s3/snQaSFbbUMcFFZ086Mw==} - engines: {node: '>=16.0.0'} - - '@smithy/middleware-stack@3.0.11': - resolution: {integrity: sha512-1HGo9a6/ikgOMrTrWL/WiN9N8GSVYpuRQO5kjstAq4CvV59bjqnh7TbdXGQ4vxLD3xlSjfBjq5t1SOELePsLnA==} - engines: {node: '>=16.0.0'} - - '@smithy/node-config-provider@3.1.12': - resolution: {integrity: sha512-O9LVEu5J/u/FuNlZs+L7Ikn3lz7VB9hb0GtPT9MQeiBmtK8RSY3ULmsZgXhe6VAlgTw0YO+paQx4p8xdbs43vQ==} - engines: {node: '>=16.0.0'} - - '@smithy/node-http-handler@3.3.3': - resolution: {integrity: sha512-BrpZOaZ4RCbcJ2igiSNG16S+kgAc65l/2hmxWdmhyoGWHTLlzQzr06PXavJp9OBlPEG/sHlqdxjWmjzV66+BSQ==} - engines: {node: '>=16.0.0'} - - '@smithy/property-provider@3.1.11': - resolution: {integrity: sha512-I/+TMc4XTQ3QAjXfOcUWbSS073oOEAxgx4aZy8jHaf8JQnRkq2SZWw8+PfDtBvLUjcGMdxl+YwtzWe6i5uhL/A==} - engines: {node: '>=16.0.0'} - - '@smithy/protocol-http@4.1.8': - resolution: {integrity: sha512-hmgIAVyxw1LySOwkgMIUN0kjN8TG9Nc85LJeEmEE/cNEe2rkHDUWhnJf2gxcSRFLWsyqWsrZGw40ROjUogg+Iw==} - engines: {node: '>=16.0.0'} - - '@smithy/querystring-builder@3.0.11': - resolution: {integrity: sha512-u+5HV/9uJaeLj5XTb6+IEF/dokWWkEqJ0XiaRRogyREmKGUgZnNecLucADLdauWFKUNbQfulHFEZEdjwEBjXRg==} - engines: {node: '>=16.0.0'} - - '@smithy/querystring-parser@3.0.11': - resolution: {integrity: sha512-Je3kFvCsFMnso1ilPwA7GtlbPaTixa3WwC+K21kmMZHsBEOZYQaqxcMqeFFoU7/slFjKDIpiiPydvdJm8Q/MCw==} - engines: {node: '>=16.0.0'} - - '@smithy/service-error-classification@3.0.11': - resolution: {integrity: sha512-QnYDPkyewrJzCyaeI2Rmp7pDwbUETe+hU8ADkXmgNusO1bgHBH7ovXJiYmba8t0fNfJx75fE8dlM6SEmZxheog==} - engines: {node: '>=16.0.0'} - - '@smithy/shared-ini-file-loader@3.1.12': - resolution: {integrity: sha512-1xKSGI+U9KKdbG2qDvIR9dGrw3CNx+baqJfyr0igKEpjbHL5stsqAesYBzHChYHlelWtb87VnLWlhvfCz13H8Q==} - engines: {node: '>=16.0.0'} - - '@smithy/signature-v4@4.2.4': - resolution: {integrity: sha512-5JWeMQYg81TgU4cG+OexAWdvDTs5JDdbEZx+Qr1iPbvo91QFGzjy0IkXAKaXUHqmKUJgSHK0ZxnCkgZpzkeNTA==} - engines: {node: '>=16.0.0'} - - '@smithy/smithy-client@3.7.0': - resolution: {integrity: sha512-9wYrjAZFlqWhgVo3C4y/9kpc68jgiSsKUnsFPzr/MSiRL93+QRDafGTfhhKAb2wsr69Ru87WTiqSfQusSmWipA==} - engines: {node: '>=16.0.0'} - - '@smithy/types@3.7.2': - resolution: {integrity: sha512-bNwBYYmN8Eh9RyjS1p2gW6MIhSO2rl7X9QeLM8iTdcGRP+eDiIWDt66c9IysCc22gefKszZv+ubV9qZc7hdESg==} - engines: {node: '>=16.0.0'} - - '@smithy/url-parser@3.0.11': - resolution: {integrity: sha512-TmlqXkSk8ZPhfc+SQutjmFr5FjC0av3GZP4B/10caK1SbRwe/v+Wzu/R6xEKxoNqL+8nY18s1byiy6HqPG37Aw==} - - '@smithy/util-base64@3.0.0': - resolution: {integrity: sha512-Kxvoh5Qtt0CDsfajiZOCpJxgtPHXOKwmM+Zy4waD43UoEMA+qPxxa98aE/7ZhdnBFZFXMOiBR5xbcaMhLtznQQ==} - engines: {node: '>=16.0.0'} - - '@smithy/util-body-length-browser@3.0.0': - resolution: {integrity: sha512-cbjJs2A1mLYmqmyVl80uoLTJhAcfzMOyPgjwAYusWKMdLeNtzmMz9YxNl3/jRLoxSS3wkqkf0jwNdtXWtyEBaQ==} - - '@smithy/util-body-length-node@3.0.0': - resolution: {integrity: sha512-Tj7pZ4bUloNUP6PzwhN7K386tmSmEET9QtQg0TgdNOnxhZvCssHji+oZTUIuzxECRfG8rdm2PMw2WCFs6eIYkA==} - engines: {node: '>=16.0.0'} - - '@smithy/util-buffer-from@2.2.0': - resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-buffer-from@3.0.0': - resolution: {integrity: sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==} - engines: {node: '>=16.0.0'} - - '@smithy/util-config-provider@3.0.0': - resolution: {integrity: sha512-pbjk4s0fwq3Di/ANL+rCvJMKM5bzAQdE5S/6RL5NXgMExFAi6UgQMPOm5yPaIWPpr+EOXKXRonJ3FoxKf4mCJQ==} - engines: {node: '>=16.0.0'} - - '@smithy/util-defaults-mode-browser@3.0.34': - resolution: {integrity: sha512-FumjjF631lR521cX+svMLBj3SwSDh9VdtyynTYDAiBDEf8YPP5xORNXKQ9j0105o5+ARAGnOOP/RqSl40uXddA==} - engines: {node: '>= 10.0.0'} - - '@smithy/util-defaults-mode-node@3.0.34': - resolution: {integrity: sha512-vN6aHfzW9dVVzkI0wcZoUXvfjkl4CSbM9nE//08lmUMyf00S75uuCpTrqF9uD4bD9eldIXlt53colrlwKAT8Gw==} - engines: {node: '>= 10.0.0'} - - '@smithy/util-endpoints@2.1.7': - resolution: {integrity: sha512-tSfcqKcN/Oo2STEYCABVuKgJ76nyyr6skGl9t15hs+YaiU06sgMkN7QYjo0BbVw+KT26zok3IzbdSOksQ4YzVw==} - engines: {node: '>=16.0.0'} - - '@smithy/util-hex-encoding@3.0.0': - resolution: {integrity: sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==} - engines: {node: '>=16.0.0'} - - '@smithy/util-middleware@3.0.11': - resolution: {integrity: sha512-dWpyc1e1R6VoXrwLoLDd57U1z6CwNSdkM69Ie4+6uYh2GC7Vg51Qtan7ITzczuVpqezdDTKJGJB95fFvvjU/ow==} - engines: {node: '>=16.0.0'} - - '@smithy/util-retry@3.0.11': - resolution: {integrity: sha512-hJUC6W7A3DQgaee3Hp9ZFcOxVDZzmBIRBPlUAk8/fSOEl7pE/aX7Dci0JycNOnm9Mfr0KV2XjIlUOcGWXQUdVQ==} - engines: {node: '>=16.0.0'} - - '@smithy/util-stream@3.3.4': - resolution: {integrity: sha512-SGhGBG/KupieJvJSZp/rfHHka8BFgj56eek9px4pp7lZbOF+fRiVr4U7A3y3zJD8uGhxq32C5D96HxsTC9BckQ==} - engines: {node: '>=16.0.0'} - - '@smithy/util-uri-escape@3.0.0': - resolution: {integrity: sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg==} - engines: {node: '>=16.0.0'} - - '@smithy/util-utf8@2.3.0': - resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} - engines: {node: '>=14.0.0'} - - '@smithy/util-utf8@3.0.0': - resolution: {integrity: sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==} - engines: {node: '>=16.0.0'} - - '@smithy/util-waiter@3.2.0': - resolution: {integrity: sha512-PpjSboaDUE6yl+1qlg3Si57++e84oXdWGbuFUSAciXsVfEZJJJupR2Nb0QuXHiunt2vGR+1PTizOMvnUPaG2Qg==} - engines: {node: '>=16.0.0'} - - '@swc/helpers@0.5.23': - resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - - '@szmarczak/http-timer@4.0.6': - resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} - engines: {node: '>=10'} - '@tapico/node-openfeature-posthog@1.1.4': resolution: {integrity: sha512-vhCvvrBkPODBbsHY+TB5nFyqzeNx77xS+JWghyKW19F3fsWI7M4EGyHVL/CZQhw0KQTl6m6kTf276Gw+N5DsTA==} engines: {node: '>=20.12.0'} peerDependencies: '@opentelemetry/api': ^1.8.0 - '@tootallnate/once@2.0.1': - resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} - engines: {node: '>= 10'} - '@turbo/darwin-64@2.9.18': resolution: {integrity: sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw==} cpu: [x64] @@ -1910,75 +1030,27 @@ packages: cpu: [arm64] os: [win32] - '@types/cacheable-request@6.0.3': - resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} - - '@types/caseless@0.12.5': - resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} - - '@types/command-line-args@5.2.3': - resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==} - - '@types/command-line-usage@5.0.4': - resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==} - '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/http-cache-semantics@4.2.0': - resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} - - '@types/js-yaml@4.0.9': - resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/jsonwebtoken@9.0.10': - resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} - - '@types/keyv@3.1.4': - resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} - - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/node-fetch@2.6.13': - resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} - '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} '@types/node@22.19.21': resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} - '@types/node@24.13.2': - resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} - '@types/qrcode@1.5.5': resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==} - '@types/request@2.48.13': - resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} - - '@types/responselike@1.0.3': - resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - - '@types/stream-buffers@3.0.8': - resolution: {integrity: sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==} - - '@types/tough-cookie@4.0.5': - resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} - '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@types/uuid@9.0.8': - resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} - '@typescript-eslint/eslint-plugin@8.61.1': resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2067,22 +1139,6 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} - ably@2.23.0: - resolution: {integrity: sha512-hDmCmNNS/XS/+1g00kVmuFkNW2PPPgeerX7bAp2ALMBsH7nTesvJwvZs/x9VEljBpB8mS0oWDDrLkitJDkUyRg==} - engines: {node: '>=16'} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - peerDependenciesMeta: - react: - optional: true - react-dom: - optional: true - - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2104,10 +1160,6 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - algoliasearch@5.55.0: - resolution: {integrity: sha512-af+rI+tUVeS9KWHPAZQHIHPOIC3StPRR6IwQu2nz1aQoTL6Gs5Ty3KsHCgbXMHOpoh9QqSjq8F3KJ8xmaCZSBA==} - engines: {node: '>= 14.0.0'} - ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -2116,38 +1168,22 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} anynum@1.0.1: resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} - apache-arrow@21.1.0: - resolution: {integrity: sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==} - hasBin: true - argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - array-back@6.2.3: - resolution: {integrity: sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==} - engines: {node: '>=12.17'} - array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -2176,14 +1212,6 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} - arrify@2.0.1: - resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} - engines: {node: '>=8'} - - assert-plus@1.0.0: - resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} - engines: {node: '>=0.8'} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -2192,9 +1220,6 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} - async-retry@1.3.3: - resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} - asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2209,14 +1234,6 @@ packages: axios@1.18.0: resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==} - b4a@1.8.1: - resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} - peerDependencies: - react-native-b4a: '*' - peerDependenciesMeta: - react-native-b4a: - optional: true - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2224,51 +1241,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - bare-events@2.9.1: - resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} - peerDependencies: - bare-abort-controller: '*' - peerDependenciesMeta: - bare-abort-controller: - optional: true - - bare-fs@4.7.2: - resolution: {integrity: sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==} - engines: {bare: '>=1.16.0'} - peerDependencies: - bare-buffer: '*' - peerDependenciesMeta: - bare-buffer: - optional: true - - bare-os@3.9.1: - resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} - engines: {bare: '>=1.14.0'} - - bare-path@3.0.1: - resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==} - - bare-stream@2.13.3: - resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} - peerDependencies: - bare-abort-controller: '*' - bare-buffer: '*' - bare-events: '*' - peerDependenciesMeta: - bare-abort-controller: - optional: true - bare-buffer: - optional: true - bare-events: - optional: true - - bare-url@2.4.5: - resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} - - base64-js@1.0.2: - resolution: {integrity: sha512-ZXBDPMt/v/8fsIqn+Z5VwrhdR6jVka0bYobHdGia0Nxi7BJ9i/Uvml3AocHIBtIIBhZjBw5MR0aR4ROs/8+SNg==} - engines: {node: '>= 0.4'} - base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2276,21 +1248,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} - bignumber.js@9.3.1: - resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} - - bops@1.0.1: - resolution: {integrity: sha512-qCMBuZKP36tELrrgXpAfM+gHzqa0nLsWZ+L37ncsb8txYlnAoxOPpVp+g7fK0sGkMXfA0wl8uQkESqw3v4HNag==} - - bowser@2.14.1: - resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} - brace-expansion@2.1.1: - resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} - brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -2299,12 +1259,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - - buffer@5.6.0: - resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==} - buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2318,14 +1272,6 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - cacheable-lookup@5.0.4: - resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} - engines: {node: '>=10.6.0'} - - cacheable-request@7.0.4: - resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} - engines: {node: '>=8'} - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2350,10 +1296,6 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} - chalk-template@0.4.0: - resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} - engines: {node: '>=12'} - chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2372,13 +1314,6 @@ packages: cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - clone-response@1.0.3: - resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} - cluster-key-slot@1.1.1: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} @@ -2394,19 +1329,6 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - command-line-args@6.0.2: - resolution: {integrity: sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==} - engines: {node: '>=12.20'} - peerDependencies: - '@75lb/nature': latest - peerDependenciesMeta: - '@75lb/nature': - optional: true - - command-line-usage@7.0.4: - resolution: {integrity: sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==} - engines: {node: '>=12.20.0'} - commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -2424,17 +1346,10 @@ packages: core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} - core-util-is@1.0.2: - resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - data-uri-to-buffer@4.0.1: - resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} - engines: {node: '>= 12'} - data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2468,10 +1383,6 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -2479,10 +1390,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - defer-to-connect@2.0.1: - resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} - engines: {node: '>=10'} - define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -2499,10 +1406,6 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -2529,24 +1432,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - duplexify@4.1.3: - resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} - - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -2596,10 +1484,6 @@ packages: engines: {node: '>=18'} hasBin: true - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2692,41 +1576,16 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} - - events-universal@1.0.1: - resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} - - events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - extend@3.0.2: - resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} - extsprintf@1.4.1: - resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} - engines: {'0': node >=0.6.0} - - farmhash-modern@1.1.0: - resolution: {integrity: sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==} - engines: {node: '>=18.0.0'} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-fifo@1.3.2: - resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -2740,24 +1599,13 @@ packages: fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} - fast-xml-parser@4.4.1: - resolution: {integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==} - hasBin: true - fast-xml-parser@5.9.3: resolution: {integrity: sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==} hasBin: true - fastestsmallesttextencoderdecoder@1.0.22: - resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==} - fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - faye-websocket@0.11.4: - resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} - engines: {node: '>=0.8.0'} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2767,10 +1615,6 @@ packages: picomatch: optional: true - fetch-blob@3.2.0: - resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} - engines: {node: ^12.20 || >= 14.13} - fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} @@ -2782,15 +1626,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-replace@5.0.2: - resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==} - engines: {node: '>=14'} - peerDependencies: - '@75lb/nature': latest - peerDependenciesMeta: - '@75lb/nature': - optional: true - find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2799,10 +1634,6 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - firebase-admin@14.1.0: - resolution: {integrity: sha512-GdHh6vHWm9LVRt+3hINWczaA7fPwnN/l4xZdqzn+wNPYErrLI1x6u1mvAJM/5IGgYI9EqQgLt1EyBG8ok/hWCg==} - engines: {node: '>=22'} - fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -2810,9 +1641,6 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatbuffers@25.9.23: - resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} - flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} @@ -2829,22 +1657,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - form-data@2.5.6: - resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==} - engines: {node: '>= 0.12'} - form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} - formdata-polyfill@4.0.10: - resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} - engines: {node: '>=12.20.0'} - fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -2865,36 +1681,9 @@ packages: resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} engines: {node: '>= 0.4'} - functional-red-black-tree@1.0.1: - resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} - functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - gaxios@6.7.1: - resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} - engines: {node: '>=14'} - - gaxios@7.1.3: - resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} - engines: {node: '>=18'} - - gaxios@7.1.5: - resolution: {integrity: sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==} - engines: {node: '>=18'} - - gcp-metadata@6.1.1: - resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} - engines: {node: '>=14'} - - gcp-metadata@8.1.2: - resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} - engines: {node: '>=18'} - - gcp-metadata@8.1.3: - resolution: {integrity: sha512-ziTrzUhhpL9Zk5k0HHzgP/KIpWDJT0VMBC/ynt/QIBvTW+UUcSivQRl6VlwTf/EilDxtSWklHoRsKy1c4k+59w==} - engines: {node: '>=18'} - generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -2911,10 +1700,6 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -2927,11 +1712,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2944,53 +1724,13 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} - google-auth-library@10.5.0: - resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} - engines: {node: '>=18'} - - google-auth-library@10.9.0: - resolution: {integrity: sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==} - engines: {node: '>=18'} - - google-auth-library@9.15.1: - resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} - engines: {node: '>=14'} - - google-gax@5.0.7: - resolution: {integrity: sha512-EhiqaWWJ+9h7sCcKJTsoo6tMcjokVHhWsbSuWCnZJT4vIBP3y4mAoFLnt9SzgkVZeq24ZsFaArr06nnYYku2yA==} - engines: {node: '>=18'} - - google-logging-utils@0.0.2: - resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} - engines: {node: '>=14'} - - google-logging-utils@1.1.3: - resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} - engines: {node: '>=14'} - - google-logging-utils@1.1.4: - resolution: {integrity: sha512-LXxE6ND+JHhDPYtPFt3oeWrjxFPrnRZCZ4At6lpbi1ZDSAN7bmGET9s53vvARbxT93p+xpeDUbc/nCR4C+7vcw==} - engines: {node: '>=14'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - got@11.8.6: - resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} - engines: {node: '>=10.19.0'} - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - gtoken@7.1.0: - resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} - engines: {node: '>=14.0.0'} - - gtoken@8.0.0: - resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} - engines: {node: '>=18'} - has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -3018,38 +1758,9 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - heap-js@2.7.1: - resolution: {integrity: sha512-EQfezRg0NCZGNlhlDR3Evrw1FVL2G3LhU7EgPoxufQKruNBSYA8MiRPHeWbU+36o+Fhel0wMwM+sLEiBAlNLJA==} - engines: {node: '>=10.0.0'} - - hpagent@1.2.0: - resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} - engines: {node: '>=14'} - - html-entities@2.6.0: - resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} - - http-cache-semantics@4.2.0: - resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} - - http-parser-js@0.5.10: - resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} - - http-proxy-agent@5.0.0: - resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} - engines: {node: '>= 6'} - - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - - http2-wrapper@1.0.3: - resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} - engines: {node: '>=10.19.0'} - - https-proxy-agent@5.0.1: - resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} - engines: {node: '>= 6'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} @@ -3093,10 +1804,6 @@ packages: resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} engines: {node: '>=12.22.0'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} - engines: {node: '>= 12'} - is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -3105,10 +1812,6 @@ packages: resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} - is-base64@1.1.0: - resolution: {integrity: sha512-Nlhg7Z2dVC4/PTvIFkgVVNvPHSO2eR/Yd0XzhGiXCXEvWnptXlXa/clQ8aePPiMuxEGcWfzWbGw2Fe3d+Y3v1g==} - hasBin: true - is-bigint@1.1.0: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} @@ -3185,13 +1888,6 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} - is-stream-ended@0.1.4: - resolution: {integrity: sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -3233,20 +1929,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isomorphic-ws@5.0.0: - resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} - peerDependencies: - ws: '*' - - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} - jose@6.2.3: - resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} - joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -3263,17 +1948,6 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true - jsep@1.4.0: - resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} - engines: {node: '>= 10.16.0'} - - json-bigint@1.0.0: - resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} - - json-bignum@0.0.3: - resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} - engines: {node: '>=0.8'} - json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -3290,29 +1964,6 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - jsonpath-plus@10.4.0: - resolution: {integrity: sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==} - engines: {node: '>=18.0.0'} - hasBin: true - - jsonwebtoken@9.0.3: - resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} - engines: {node: '>=12', npm: '>=6'} - - jwa@2.0.1: - resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - - jwks-rsa@4.1.0: - resolution: {integrity: sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==} - engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0} - - jws@4.0.1: - resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - - kafkajs@2.2.4: - resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==} - engines: {node: '>=14.0.0'} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3328,9 +1979,6 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} - limiter@1.1.5: - resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} - lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -3346,70 +1994,19 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash.camelcase@4.3.0: - resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} - - lodash.clonedeep@4.5.0: - resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} - - lodash.includes@4.3.0: - resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - - lodash.isboolean@3.0.3: - resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - - lodash.isinteger@4.0.4: - resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} - - lodash.isnumber@3.0.3: - resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - - lodash.snakecase@4.1.1: - resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} - lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} - loglevel@1.9.2: - resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} - engines: {node: '>= 0.6.0'} - - long@5.3.2: - resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} - loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - lowercase-keys@2.0.0: - resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} - engines: {node: '>=8'} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - lru-cache@11.5.1: - resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} - engines: {node: 20 || >=22} - lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - lru-memoizer@3.0.0: - resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3433,19 +2030,6 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime@3.0.0: - resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} - engines: {node: '>=10.0.0'} - hasBin: true - - mimic-response@1.0.1: - resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} - engines: {node: '>=4'} - - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -3453,17 +2037,9 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} - engines: {node: '>=16 || 14 >=14.17'} - minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -3493,11 +2069,6 @@ packages: new-date@1.0.3: resolution: {integrity: sha512-0fsVvQPbo2I18DT2zVHpezmeeNYV2JaJSrseiHLc17GNOxJzUdx5mvSigPu8LtIfZSij5i1wXnXFspEs2CD6hA==} - node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} - deprecated: Use your platform's native DOMException instead - node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} @@ -3511,21 +2082,6 @@ packages: encoding: optional: true - node-fetch@3.3.2: - resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - node-forge@1.4.0: - resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} - engines: {node: '>= 6.13.0'} - - normalize-url@6.1.0: - resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} - engines: {node: '>=10'} - - oauth4webapi@3.8.6: - resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} - obj-case@0.2.1: resolution: {integrity: sha512-PquYBBTy+Y6Ob/O2574XHhDtHJlV1cJHMCgW+rDRc9J5hhmRelJB3k5dTK/3cVmFVtzvAKuENeuLpoyTzMzkOg==} @@ -3533,10 +2089,6 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -3569,12 +2121,6 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - openid-client@6.8.4: - resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3586,14 +2132,6 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - p-cancelable@2.1.1: - resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} - engines: {node: '>=8'} - - p-defer@3.0.0: - resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==} - engines: {node: '>=8'} - p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -3622,9 +2160,6 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} @@ -3647,10 +2182,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -3743,9 +2274,6 @@ packages: rxjs: optional: true - postmark@4.0.7: - resolution: {integrity: sha512-DjNniUl1XNCGUKhCR98ePd5gv16rlUAVKKaU9TUqnE3hDSqfT9XDulu1idjagQmdyGscqnRtXk/puAEiYMeevg==} - preact@10.29.2: resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} @@ -3766,29 +2294,14 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - proto3-json-serializer@3.0.4: - resolution: {integrity: sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==} - engines: {node: '>=18'} - - protobufjs@7.6.4: - resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} - engines: {node: '>=12.0.0'} - proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - pusher@5.3.4: - resolution: {integrity: sha512-gA7NpPRsQW9qpry/Ov+o9KKIPaRMziiDoEtiZ6EF0+QiSdsnT49+dH7+hf0Dvd4d+HEGQDHp6VXtrVM5DDrpmw==} - engines: {node: '>= 10.0.0'} - qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -3806,18 +2319,10 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - quick-lru@5.1.1: - resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} - engines: {node: '>=10'} - read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -3849,9 +2354,6 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - resolve-alpn@1.2.1: - resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} - resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3865,32 +2367,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true - responselike@2.0.1: - resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} - - retry-request@7.0.2: - resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} - engines: {node: '>=14'} - - retry-request@8.0.3: - resolution: {integrity: sha512-qqoc4kkGgP9cmQDWELlOpAmfgJOg0Yi7MT82ZjiPWu451ayju4itwomjM4/dBEliify8C1b3tSaeCOldugtwPQ==} - engines: {node: '>=18'} - - retry@0.13.1: - resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} - engines: {node: '>= 4'} - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rfc4648@1.5.4: - resolution: {integrity: sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==} - - rimraf@5.0.10: - resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} - hasBin: true - rollup@4.62.0: resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3903,9 +2383,6 @@ packages: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -3921,9 +2398,6 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - secure-json-parse@3.0.2: - resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3988,22 +2462,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.7.0: resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} engines: {node: '>= 18'} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.9: - resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -4038,30 +2500,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - stream-browserify@3.0.0: - resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} - - stream-buffers@3.0.3: - resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} - engines: {node: '>= 0.10.0'} - - stream-events@1.0.5: - resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} - - stream-shift@1.0.3: - resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} - - streamx@2.28.0: - resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - string.prototype.trim@1.2.11: resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} engines: {node: '>= 0.4'} @@ -4074,17 +2516,10 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -4093,15 +2528,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strnum@1.1.2: - resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} - strnum@2.4.1: resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} - stubs@3.0.0: - resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} - sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -4115,34 +2544,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - table-layout@4.1.1: - resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} - engines: {node: '>=12.17'} - - tar-fs@3.1.2: - resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} - - tar-stream@3.2.0: - resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} - - teeny-request@10.1.3: - resolution: {integrity: sha512-5yDliI1uWkYPo7W+Zvrxg6YmoWuj5iC5EydewqrRTvc68nyMTZhlPPlLg6cptUGfbQAb+N9XDPDPzF6N081lug==} - engines: {node: '>=18'} - - teeny-request@9.0.0: - resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} - engines: {node: '>=14'} - - teex@1.0.1: - resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} - term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} - text-decoder@1.2.7: - resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -4179,9 +2584,6 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - to-utf8@0.0.1: - resolution: {integrity: sha512-zks18/TWT1iHO3v0vFp5qLKOG27m67ycq/Y7a7cTiRuUNlc4gf3HGnkRgMv0NyhnfTamtkYBJl+YeD1/j07gBQ==} - tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -4227,12 +2629,6 @@ packages: resolution: {integrity: sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg==} hasBin: true - tweetnacl-util@0.15.1: - resolution: {integrity: sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==} - - tweetnacl@1.0.3: - resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4265,23 +2661,9 @@ packages: engines: {node: '>=14.17'} hasBin: true - typesense@3.0.6: - resolution: {integrity: sha512-d3LL1qOLS8FCRxgAOqH+uDuK+VVA+/HYI1frU9fjYVwOg68mg/dX6XTtZ4yKKAkDCasB/se5uh/ZpMdOz/uJNg==} - engines: {node: '>=18'} - peerDependencies: - '@babel/runtime': ^7.23.2 - - typical@7.3.0: - resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} - engines: {node: '>=12.17'} - ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - ulid@2.4.0: - resolution: {integrity: sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==} - hasBin: true - ulid@3.0.2: resolution: {integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==} hasBin: true @@ -4293,13 +2675,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - - undici@6.27.0: - resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} - engines: {node: '>=18.17'} - unfetch@3.1.2: resolution: {integrity: sha512-L0qrK7ZeAudGiKYw6nzFjnJ2D5WHblUBwmHIqtPS6oKUd+Hcpk7/hKsSmcHsTlpd1TbTNsiRBUKRq3bHLNIqIw==} @@ -4313,18 +2688,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). - hasBin: true - - verror@1.10.1: - resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} - engines: {node: '>=0.6.0'} - vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -4386,24 +2749,12 @@ packages: jsdom: optional: true - web-streams-polyfill@3.3.3: - resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} - engines: {node: '>= 8'} - web-vitals@5.3.0: resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==} webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - websocket-driver@0.7.5: - resolution: {integrity: sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==} - engines: {node: '>=0.8.0'} - - websocket-extensions@0.1.4: - resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} - engines: {node: '>=0.8.0'} - whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -4440,37 +2791,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wordwrapjs@5.1.1: - resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} - engines: {node: '>=12.17'} - wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - xml-naming@0.1.0: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} @@ -4478,10 +2802,6 @@ packages: y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} @@ -4494,18 +2814,10 @@ packages: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - yargs@15.4.1: resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} engines: {node: '>=8'} - yargs@17.7.3: - resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} - engines: {node: '>=12'} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -4515,844 +2827,114 @@ packages: snapshots: - '@ably/msgpack-js@0.4.1': - dependencies: - bops: 1.0.1 + '@babel/runtime@7.29.7': {} - '@algolia/abtesting@1.21.0': + '@changesets/apply-release-plan@7.1.1': dependencies: - '@algolia/client-common': 5.55.0 - '@algolia/requester-browser-xhr': 5.55.0 - '@algolia/requester-fetch': 5.55.0 - '@algolia/requester-node-http': 5.55.0 + '@changesets/config': 3.1.4 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.8.4 - '@algolia/client-abtesting@5.55.0': + '@changesets/assemble-release-plan@6.0.10': dependencies: - '@algolia/client-common': 5.55.0 - '@algolia/requester-browser-xhr': 5.55.0 - '@algolia/requester-fetch': 5.55.0 - '@algolia/requester-node-http': 5.55.0 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.8.4 - '@algolia/client-analytics@5.55.0': + '@changesets/changelog-git@0.2.1': dependencies: - '@algolia/client-common': 5.55.0 - '@algolia/requester-browser-xhr': 5.55.0 - '@algolia/requester-fetch': 5.55.0 - '@algolia/requester-node-http': 5.55.0 - - '@algolia/client-common@5.55.0': {} + '@changesets/types': 6.1.0 - '@algolia/client-insights@5.55.0': + '@changesets/cli@2.31.0(@types/node@22.19.21)': dependencies: - '@algolia/client-common': 5.55.0 - '@algolia/requester-browser-xhr': 5.55.0 - '@algolia/requester-fetch': 5.55.0 - '@algolia/requester-node-http': 5.55.0 + '@changesets/apply-release-plan': 7.1.1 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.4 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/get-release-plan': 4.0.16 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3(@types/node@22.19.21) + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + enquirer: 2.4.1 + fs-extra: 7.0.1 + mri: 1.2.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.8.4 + spawndamnit: 3.0.1 + term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' - '@algolia/client-personalization@5.55.0': + '@changesets/config@3.1.4': dependencies: - '@algolia/client-common': 5.55.0 - '@algolia/requester-browser-xhr': 5.55.0 - '@algolia/requester-fetch': 5.55.0 - '@algolia/requester-node-http': 5.55.0 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/logger': 0.1.1 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 - '@algolia/client-query-suggestions@5.55.0': + '@changesets/errors@0.2.0': dependencies: - '@algolia/client-common': 5.55.0 - '@algolia/requester-browser-xhr': 5.55.0 - '@algolia/requester-fetch': 5.55.0 - '@algolia/requester-node-http': 5.55.0 + extendable-error: 0.1.7 - '@algolia/client-search@5.55.0': + '@changesets/get-dependents-graph@2.1.4': dependencies: - '@algolia/client-common': 5.55.0 - '@algolia/requester-browser-xhr': 5.55.0 - '@algolia/requester-fetch': 5.55.0 - '@algolia/requester-node-http': 5.55.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.8.4 - '@algolia/ingestion@1.55.0': + '@changesets/get-release-plan@4.0.16': dependencies: - '@algolia/client-common': 5.55.0 - '@algolia/requester-browser-xhr': 5.55.0 - '@algolia/requester-fetch': 5.55.0 - '@algolia/requester-node-http': 5.55.0 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/config': 3.1.4 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 - '@algolia/monitoring@1.55.0': - dependencies: - '@algolia/client-common': 5.55.0 - '@algolia/requester-browser-xhr': 5.55.0 - '@algolia/requester-fetch': 5.55.0 - '@algolia/requester-node-http': 5.55.0 + '@changesets/get-version-range-type@0.4.0': {} - '@algolia/recommend@5.55.0': + '@changesets/git@3.0.4': dependencies: - '@algolia/client-common': 5.55.0 - '@algolia/requester-browser-xhr': 5.55.0 - '@algolia/requester-fetch': 5.55.0 - '@algolia/requester-node-http': 5.55.0 + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 - '@algolia/requester-browser-xhr@5.55.0': + '@changesets/logger@0.1.1': dependencies: - '@algolia/client-common': 5.55.0 + picocolors: 1.1.1 - '@algolia/requester-fetch@5.55.0': - dependencies: - '@algolia/client-common': 5.55.0 - - '@algolia/requester-node-http@5.55.0': - dependencies: - '@algolia/client-common': 5.55.0 - - '@aws-crypto/crc32@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.696.0 - tslib: 2.8.1 - - '@aws-crypto/crc32c@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.696.0 - tslib: 2.8.1 - - '@aws-crypto/sha1-browser@5.2.0': - dependencies: - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.696.0 - '@aws-sdk/util-locate-window': 3.965.8 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-browser@5.2.0': - dependencies: - '@aws-crypto/sha256-js': 5.2.0 - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.696.0 - '@aws-sdk/util-locate-window': 3.965.8 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-js@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.696.0 - tslib: 2.8.1 - - '@aws-crypto/supports-web-crypto@5.2.0': - dependencies: - tslib: 2.8.1 - - '@aws-crypto/util@5.2.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-sdk/client-s3@3.700.0': - dependencies: - '@aws-crypto/sha1-browser': 5.2.0 - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/client-sso-oidc': 3.699.0(@aws-sdk/client-sts@3.699.0) - '@aws-sdk/client-sts': 3.699.0 - '@aws-sdk/core': 3.696.0 - '@aws-sdk/credential-provider-node': 3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.699.0) - '@aws-sdk/middleware-bucket-endpoint': 3.696.0 - '@aws-sdk/middleware-expect-continue': 3.696.0 - '@aws-sdk/middleware-flexible-checksums': 3.697.0 - '@aws-sdk/middleware-host-header': 3.696.0 - '@aws-sdk/middleware-location-constraint': 3.696.0 - '@aws-sdk/middleware-logger': 3.696.0 - '@aws-sdk/middleware-recursion-detection': 3.696.0 - '@aws-sdk/middleware-sdk-s3': 3.696.0 - '@aws-sdk/middleware-ssec': 3.696.0 - '@aws-sdk/middleware-user-agent': 3.696.0 - '@aws-sdk/region-config-resolver': 3.696.0 - '@aws-sdk/signature-v4-multi-region': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@aws-sdk/util-endpoints': 3.696.0 - '@aws-sdk/util-user-agent-browser': 3.696.0 - '@aws-sdk/util-user-agent-node': 3.696.0 - '@aws-sdk/xml-builder': 3.696.0 - '@smithy/config-resolver': 3.0.13 - '@smithy/core': 2.5.7 - '@smithy/eventstream-serde-browser': 3.0.14 - '@smithy/eventstream-serde-config-resolver': 3.0.11 - '@smithy/eventstream-serde-node': 3.0.13 - '@smithy/fetch-http-handler': 4.1.3 - '@smithy/hash-blob-browser': 3.1.10 - '@smithy/hash-node': 3.0.11 - '@smithy/hash-stream-node': 3.1.10 - '@smithy/invalid-dependency': 3.0.11 - '@smithy/md5-js': 3.0.11 - '@smithy/middleware-content-length': 3.0.13 - '@smithy/middleware-endpoint': 3.2.8 - '@smithy/middleware-retry': 3.0.34 - '@smithy/middleware-serde': 3.0.11 - '@smithy/middleware-stack': 3.0.11 - '@smithy/node-config-provider': 3.1.12 - '@smithy/node-http-handler': 3.3.3 - '@smithy/protocol-http': 4.1.8 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - '@smithy/url-parser': 3.0.11 - '@smithy/util-base64': 3.0.0 - '@smithy/util-body-length-browser': 3.0.0 - '@smithy/util-body-length-node': 3.0.0 - '@smithy/util-defaults-mode-browser': 3.0.34 - '@smithy/util-defaults-mode-node': 3.0.34 - '@smithy/util-endpoints': 2.1.7 - '@smithy/util-middleware': 3.0.11 - '@smithy/util-retry': 3.0.11 - '@smithy/util-stream': 3.3.4 - '@smithy/util-utf8': 3.0.0 - '@smithy/util-waiter': 3.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-sqs@3.699.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/client-sso-oidc': 3.699.0(@aws-sdk/client-sts@3.699.0) - '@aws-sdk/client-sts': 3.699.0 - '@aws-sdk/core': 3.696.0 - '@aws-sdk/credential-provider-node': 3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.699.0) - '@aws-sdk/middleware-host-header': 3.696.0 - '@aws-sdk/middleware-logger': 3.696.0 - '@aws-sdk/middleware-recursion-detection': 3.696.0 - '@aws-sdk/middleware-sdk-sqs': 3.696.0 - '@aws-sdk/middleware-user-agent': 3.696.0 - '@aws-sdk/region-config-resolver': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@aws-sdk/util-endpoints': 3.696.0 - '@aws-sdk/util-user-agent-browser': 3.696.0 - '@aws-sdk/util-user-agent-node': 3.696.0 - '@smithy/config-resolver': 3.0.13 - '@smithy/core': 2.5.7 - '@smithy/fetch-http-handler': 4.1.3 - '@smithy/hash-node': 3.0.11 - '@smithy/invalid-dependency': 3.0.11 - '@smithy/md5-js': 3.0.11 - '@smithy/middleware-content-length': 3.0.13 - '@smithy/middleware-endpoint': 3.2.8 - '@smithy/middleware-retry': 3.0.34 - '@smithy/middleware-serde': 3.0.11 - '@smithy/middleware-stack': 3.0.11 - '@smithy/node-config-provider': 3.1.12 - '@smithy/node-http-handler': 3.3.3 - '@smithy/protocol-http': 4.1.8 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - '@smithy/url-parser': 3.0.11 - '@smithy/util-base64': 3.0.0 - '@smithy/util-body-length-browser': 3.0.0 - '@smithy/util-body-length-node': 3.0.0 - '@smithy/util-defaults-mode-browser': 3.0.34 - '@smithy/util-defaults-mode-node': 3.0.34 - '@smithy/util-endpoints': 2.1.7 - '@smithy/util-middleware': 3.0.11 - '@smithy/util-retry': 3.0.11 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-ssm@3.699.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/client-sso-oidc': 3.699.0(@aws-sdk/client-sts@3.699.0) - '@aws-sdk/client-sts': 3.699.0 - '@aws-sdk/core': 3.696.0 - '@aws-sdk/credential-provider-node': 3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.699.0) - '@aws-sdk/middleware-host-header': 3.696.0 - '@aws-sdk/middleware-logger': 3.696.0 - '@aws-sdk/middleware-recursion-detection': 3.696.0 - '@aws-sdk/middleware-user-agent': 3.696.0 - '@aws-sdk/region-config-resolver': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@aws-sdk/util-endpoints': 3.696.0 - '@aws-sdk/util-user-agent-browser': 3.696.0 - '@aws-sdk/util-user-agent-node': 3.696.0 - '@smithy/config-resolver': 3.0.13 - '@smithy/core': 2.5.7 - '@smithy/fetch-http-handler': 4.1.3 - '@smithy/hash-node': 3.0.11 - '@smithy/invalid-dependency': 3.0.11 - '@smithy/middleware-content-length': 3.0.13 - '@smithy/middleware-endpoint': 3.2.8 - '@smithy/middleware-retry': 3.0.34 - '@smithy/middleware-serde': 3.0.11 - '@smithy/middleware-stack': 3.0.11 - '@smithy/node-config-provider': 3.1.12 - '@smithy/node-http-handler': 3.3.3 - '@smithy/protocol-http': 4.1.8 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - '@smithy/url-parser': 3.0.11 - '@smithy/util-base64': 3.0.0 - '@smithy/util-body-length-browser': 3.0.0 - '@smithy/util-body-length-node': 3.0.0 - '@smithy/util-defaults-mode-browser': 3.0.34 - '@smithy/util-defaults-mode-node': 3.0.34 - '@smithy/util-endpoints': 2.1.7 - '@smithy/util-middleware': 3.0.11 - '@smithy/util-retry': 3.0.11 - '@smithy/util-utf8': 3.0.0 - '@smithy/util-waiter': 3.2.0 - '@types/uuid': 9.0.8 - tslib: 2.8.1 - uuid: 9.0.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0)': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/client-sts': 3.699.0 - '@aws-sdk/core': 3.696.0 - '@aws-sdk/credential-provider-node': 3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.699.0) - '@aws-sdk/middleware-host-header': 3.696.0 - '@aws-sdk/middleware-logger': 3.696.0 - '@aws-sdk/middleware-recursion-detection': 3.696.0 - '@aws-sdk/middleware-user-agent': 3.696.0 - '@aws-sdk/region-config-resolver': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@aws-sdk/util-endpoints': 3.696.0 - '@aws-sdk/util-user-agent-browser': 3.696.0 - '@aws-sdk/util-user-agent-node': 3.696.0 - '@smithy/config-resolver': 3.0.13 - '@smithy/core': 2.5.7 - '@smithy/fetch-http-handler': 4.1.3 - '@smithy/hash-node': 3.0.11 - '@smithy/invalid-dependency': 3.0.11 - '@smithy/middleware-content-length': 3.0.13 - '@smithy/middleware-endpoint': 3.2.8 - '@smithy/middleware-retry': 3.0.34 - '@smithy/middleware-serde': 3.0.11 - '@smithy/middleware-stack': 3.0.11 - '@smithy/node-config-provider': 3.1.12 - '@smithy/node-http-handler': 3.3.3 - '@smithy/protocol-http': 4.1.8 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - '@smithy/url-parser': 3.0.11 - '@smithy/util-base64': 3.0.0 - '@smithy/util-body-length-browser': 3.0.0 - '@smithy/util-body-length-node': 3.0.0 - '@smithy/util-defaults-mode-browser': 3.0.34 - '@smithy/util-defaults-mode-node': 3.0.34 - '@smithy/util-endpoints': 2.1.7 - '@smithy/util-middleware': 3.0.11 - '@smithy/util-retry': 3.0.11 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-sso@3.696.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.696.0 - '@aws-sdk/middleware-host-header': 3.696.0 - '@aws-sdk/middleware-logger': 3.696.0 - '@aws-sdk/middleware-recursion-detection': 3.696.0 - '@aws-sdk/middleware-user-agent': 3.696.0 - '@aws-sdk/region-config-resolver': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@aws-sdk/util-endpoints': 3.696.0 - '@aws-sdk/util-user-agent-browser': 3.696.0 - '@aws-sdk/util-user-agent-node': 3.696.0 - '@smithy/config-resolver': 3.0.13 - '@smithy/core': 2.5.7 - '@smithy/fetch-http-handler': 4.1.3 - '@smithy/hash-node': 3.0.11 - '@smithy/invalid-dependency': 3.0.11 - '@smithy/middleware-content-length': 3.0.13 - '@smithy/middleware-endpoint': 3.2.8 - '@smithy/middleware-retry': 3.0.34 - '@smithy/middleware-serde': 3.0.11 - '@smithy/middleware-stack': 3.0.11 - '@smithy/node-config-provider': 3.1.12 - '@smithy/node-http-handler': 3.3.3 - '@smithy/protocol-http': 4.1.8 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - '@smithy/url-parser': 3.0.11 - '@smithy/util-base64': 3.0.0 - '@smithy/util-body-length-browser': 3.0.0 - '@smithy/util-body-length-node': 3.0.0 - '@smithy/util-defaults-mode-browser': 3.0.34 - '@smithy/util-defaults-mode-node': 3.0.34 - '@smithy/util-endpoints': 2.1.7 - '@smithy/util-middleware': 3.0.11 - '@smithy/util-retry': 3.0.11 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-sts@3.699.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/client-sso-oidc': 3.699.0(@aws-sdk/client-sts@3.699.0) - '@aws-sdk/core': 3.696.0 - '@aws-sdk/credential-provider-node': 3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.699.0) - '@aws-sdk/middleware-host-header': 3.696.0 - '@aws-sdk/middleware-logger': 3.696.0 - '@aws-sdk/middleware-recursion-detection': 3.696.0 - '@aws-sdk/middleware-user-agent': 3.696.0 - '@aws-sdk/region-config-resolver': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@aws-sdk/util-endpoints': 3.696.0 - '@aws-sdk/util-user-agent-browser': 3.696.0 - '@aws-sdk/util-user-agent-node': 3.696.0 - '@smithy/config-resolver': 3.0.13 - '@smithy/core': 2.5.7 - '@smithy/fetch-http-handler': 4.1.3 - '@smithy/hash-node': 3.0.11 - '@smithy/invalid-dependency': 3.0.11 - '@smithy/middleware-content-length': 3.0.13 - '@smithy/middleware-endpoint': 3.2.8 - '@smithy/middleware-retry': 3.0.34 - '@smithy/middleware-serde': 3.0.11 - '@smithy/middleware-stack': 3.0.11 - '@smithy/node-config-provider': 3.1.12 - '@smithy/node-http-handler': 3.3.3 - '@smithy/protocol-http': 4.1.8 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - '@smithy/url-parser': 3.0.11 - '@smithy/util-base64': 3.0.0 - '@smithy/util-body-length-browser': 3.0.0 - '@smithy/util-body-length-node': 3.0.0 - '@smithy/util-defaults-mode-browser': 3.0.34 - '@smithy/util-defaults-mode-node': 3.0.34 - '@smithy/util-endpoints': 2.1.7 - '@smithy/util-middleware': 3.0.11 - '@smithy/util-retry': 3.0.11 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/core@3.696.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@smithy/core': 2.5.7 - '@smithy/node-config-provider': 3.1.12 - '@smithy/property-provider': 3.1.11 - '@smithy/protocol-http': 4.1.8 - '@smithy/signature-v4': 4.2.4 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - '@smithy/util-middleware': 3.0.11 - fast-xml-parser: 4.4.1 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-env@3.696.0': - dependencies: - '@aws-sdk/core': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@smithy/property-provider': 3.1.11 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-http@3.696.0': - dependencies: - '@aws-sdk/core': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@smithy/fetch-http-handler': 4.1.3 - '@smithy/node-http-handler': 3.3.3 - '@smithy/property-provider': 3.1.11 - '@smithy/protocol-http': 4.1.8 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - '@smithy/util-stream': 3.3.4 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-ini@3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.699.0)': - dependencies: - '@aws-sdk/client-sts': 3.699.0 - '@aws-sdk/core': 3.696.0 - '@aws-sdk/credential-provider-env': 3.696.0 - '@aws-sdk/credential-provider-http': 3.696.0 - '@aws-sdk/credential-provider-process': 3.696.0 - '@aws-sdk/credential-provider-sso': 3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0)) - '@aws-sdk/credential-provider-web-identity': 3.696.0(@aws-sdk/client-sts@3.699.0) - '@aws-sdk/types': 3.696.0 - '@smithy/credential-provider-imds': 3.2.8 - '@smithy/property-provider': 3.1.11 - '@smithy/shared-ini-file-loader': 3.1.12 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - - aws-crt - - '@aws-sdk/credential-provider-node@3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.699.0)': - dependencies: - '@aws-sdk/credential-provider-env': 3.696.0 - '@aws-sdk/credential-provider-http': 3.696.0 - '@aws-sdk/credential-provider-ini': 3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0))(@aws-sdk/client-sts@3.699.0) - '@aws-sdk/credential-provider-process': 3.696.0 - '@aws-sdk/credential-provider-sso': 3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0)) - '@aws-sdk/credential-provider-web-identity': 3.696.0(@aws-sdk/client-sts@3.699.0) - '@aws-sdk/types': 3.696.0 - '@smithy/credential-provider-imds': 3.2.8 - '@smithy/property-provider': 3.1.11 - '@smithy/shared-ini-file-loader': 3.1.12 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - - '@aws-sdk/client-sts' - - aws-crt - - '@aws-sdk/credential-provider-process@3.696.0': - dependencies: - '@aws-sdk/core': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@smithy/property-provider': 3.1.11 - '@smithy/shared-ini-file-loader': 3.1.12 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-sso@3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0))': - dependencies: - '@aws-sdk/client-sso': 3.696.0 - '@aws-sdk/core': 3.696.0 - '@aws-sdk/token-providers': 3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0)) - '@aws-sdk/types': 3.696.0 - '@smithy/property-provider': 3.1.11 - '@smithy/shared-ini-file-loader': 3.1.12 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - - aws-crt - - '@aws-sdk/credential-provider-web-identity@3.696.0(@aws-sdk/client-sts@3.699.0)': - dependencies: - '@aws-sdk/client-sts': 3.699.0 - '@aws-sdk/core': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@smithy/property-provider': 3.1.11 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/lib-storage@3.700.0(@aws-sdk/client-s3@3.700.0)': - dependencies: - '@aws-sdk/client-s3': 3.700.0 - '@smithy/abort-controller': 3.1.9 - '@smithy/middleware-endpoint': 3.2.8 - '@smithy/smithy-client': 3.7.0 - buffer: 5.6.0 - events: 3.3.0 - stream-browserify: 3.0.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-bucket-endpoint@3.696.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@aws-sdk/util-arn-parser': 3.693.0 - '@smithy/node-config-provider': 3.1.12 - '@smithy/protocol-http': 4.1.8 - '@smithy/types': 3.7.2 - '@smithy/util-config-provider': 3.0.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-expect-continue@3.696.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@smithy/protocol-http': 4.1.8 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/middleware-flexible-checksums@3.697.0': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@aws-crypto/crc32c': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@smithy/is-array-buffer': 3.0.0 - '@smithy/node-config-provider': 3.1.12 - '@smithy/protocol-http': 4.1.8 - '@smithy/types': 3.7.2 - '@smithy/util-middleware': 3.0.11 - '@smithy/util-stream': 3.3.4 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-host-header@3.696.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@smithy/protocol-http': 4.1.8 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/middleware-location-constraint@3.696.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/middleware-logger@3.696.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/middleware-recursion-detection@3.696.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@smithy/protocol-http': 4.1.8 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/middleware-sdk-s3@3.696.0': - dependencies: - '@aws-sdk/core': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@aws-sdk/util-arn-parser': 3.693.0 - '@smithy/core': 2.5.7 - '@smithy/node-config-provider': 3.1.12 - '@smithy/protocol-http': 4.1.8 - '@smithy/signature-v4': 4.2.4 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - '@smithy/util-config-provider': 3.0.0 - '@smithy/util-middleware': 3.0.11 - '@smithy/util-stream': 3.3.4 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-sdk-sqs@3.696.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - '@smithy/util-hex-encoding': 3.0.0 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-ssec@3.696.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/middleware-user-agent@3.696.0': - dependencies: - '@aws-sdk/core': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@aws-sdk/util-endpoints': 3.696.0 - '@smithy/core': 2.5.7 - '@smithy/protocol-http': 4.1.8 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/region-config-resolver@3.696.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@smithy/node-config-provider': 3.1.12 - '@smithy/types': 3.7.2 - '@smithy/util-config-provider': 3.0.0 - '@smithy/util-middleware': 3.0.11 - tslib: 2.8.1 - - '@aws-sdk/s3-request-presigner@3.700.0': - dependencies: - '@aws-sdk/signature-v4-multi-region': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@aws-sdk/util-format-url': 3.696.0 - '@smithy/middleware-endpoint': 3.2.8 - '@smithy/protocol-http': 4.1.8 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/signature-v4-multi-region@3.696.0': - dependencies: - '@aws-sdk/middleware-sdk-s3': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@smithy/protocol-http': 4.1.8 - '@smithy/signature-v4': 4.2.4 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/token-providers@3.699.0(@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0))': - dependencies: - '@aws-sdk/client-sso-oidc': 3.699.0(@aws-sdk/client-sts@3.699.0) - '@aws-sdk/types': 3.696.0 - '@smithy/property-provider': 3.1.11 - '@smithy/shared-ini-file-loader': 3.1.12 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/types@3.696.0': - dependencies: - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/util-arn-parser@3.693.0': - dependencies: - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.696.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@smithy/types': 3.7.2 - '@smithy/util-endpoints': 2.1.7 - tslib: 2.8.1 - - '@aws-sdk/util-format-url@3.696.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@smithy/querystring-builder': 3.0.11 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/util-locate-window@3.965.8': - dependencies: - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-browser@3.696.0': - dependencies: - '@aws-sdk/types': 3.696.0 - '@smithy/types': 3.7.2 - bowser: 2.14.1 - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-node@3.696.0': - dependencies: - '@aws-sdk/middleware-user-agent': 3.696.0 - '@aws-sdk/types': 3.696.0 - '@smithy/node-config-provider': 3.1.12 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@aws-sdk/xml-builder@3.696.0': - dependencies: - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@babel/runtime@7.29.7': {} - - '@changesets/apply-release-plan@7.1.1': - dependencies: - '@changesets/config': 3.1.4 - '@changesets/get-version-range-type': 0.4.0 - '@changesets/git': 3.0.4 - '@changesets/should-skip-package': 0.1.2 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 - detect-indent: 6.1.0 - fs-extra: 7.0.1 - lodash.startcase: 4.4.0 - outdent: 0.5.0 - prettier: 2.8.8 - resolve-from: 5.0.0 - semver: 7.8.4 - - '@changesets/assemble-release-plan@6.0.10': - dependencies: - '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.4 - '@changesets/should-skip-package': 0.1.2 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 - semver: 7.8.4 - - '@changesets/changelog-git@0.2.1': - dependencies: - '@changesets/types': 6.1.0 - - '@changesets/cli@2.31.0(@types/node@22.19.21)': - dependencies: - '@changesets/apply-release-plan': 7.1.1 - '@changesets/assemble-release-plan': 6.0.10 - '@changesets/changelog-git': 0.2.1 - '@changesets/config': 3.1.4 - '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.4 - '@changesets/get-release-plan': 4.0.16 - '@changesets/git': 3.0.4 - '@changesets/logger': 0.1.1 - '@changesets/pre': 2.0.2 - '@changesets/read': 0.6.7 - '@changesets/should-skip-package': 0.1.2 - '@changesets/types': 6.1.0 - '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@22.19.21) - '@manypkg/get-packages': 1.1.3 - ansi-colors: 4.1.3 - enquirer: 2.4.1 - fs-extra: 7.0.1 - mri: 1.2.0 - package-manager-detector: 0.2.11 - picocolors: 1.1.1 - resolve-from: 5.0.0 - semver: 7.8.4 - spawndamnit: 3.0.1 - term-size: 2.2.1 - transitivePeerDependencies: - - '@types/node' - - '@changesets/config@3.1.4': - dependencies: - '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.4 - '@changesets/logger': 0.1.1 - '@changesets/should-skip-package': 0.1.2 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 - fs-extra: 7.0.1 - micromatch: 4.0.8 - - '@changesets/errors@0.2.0': - dependencies: - extendable-error: 0.1.7 - - '@changesets/get-dependents-graph@2.1.4': - dependencies: - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 - picocolors: 1.1.1 - semver: 7.8.4 - - '@changesets/get-release-plan@4.0.16': - dependencies: - '@changesets/assemble-release-plan': 6.0.10 - '@changesets/config': 3.1.4 - '@changesets/pre': 2.0.2 - '@changesets/read': 0.6.7 - '@changesets/types': 6.1.0 - '@manypkg/get-packages': 1.1.3 - - '@changesets/get-version-range-type@0.4.0': {} - - '@changesets/git@3.0.4': - dependencies: - '@changesets/errors': 0.2.0 - '@manypkg/get-packages': 1.1.3 - is-subdir: 1.2.0 - micromatch: 4.0.8 - spawndamnit: 3.0.1 - - '@changesets/logger@0.1.1': - dependencies: - picocolors: 1.1.1 - - '@changesets/parse@0.4.3': + '@changesets/parse@0.4.3': dependencies: '@changesets/types': 6.1.0 js-yaml: 4.2.0 @@ -5390,28 +2972,6 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 - '@elastic/elasticsearch@8.19.1': - dependencies: - '@elastic/transport': 8.10.1 - apache-arrow: 21.1.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@75lb/nature' - - supports-color - - '@elastic/transport@8.10.1': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) - debug: 4.4.3 - hpagent: 1.2.0 - ms: 2.1.3 - secure-json-parse: 3.0.2 - tslib: 2.8.1 - undici: 6.27.0 - transitivePeerDependencies: - - supports-color - '@esbuild/aix-ppc64@0.21.5': optional: true @@ -5607,148 +3167,11 @@ snapshots: '@faker-js/faker@10.5.0': {} - '@fastify/busboy@3.2.0': {} - - '@firebase/app-check-interop-types@0.3.4': {} - - '@firebase/app-types@0.9.5': + '@humanfs/core@0.19.2': dependencies: - '@firebase/logger': 0.5.1 - - '@firebase/auth-interop-types@0.2.5': {} + '@humanfs/types': 0.15.0 - '@firebase/component@0.7.3': - dependencies: - '@firebase/util': 1.15.1 - tslib: 2.8.1 - - '@firebase/database-compat@2.1.4': - dependencies: - '@firebase/component': 0.7.3 - '@firebase/database': 1.1.3 - '@firebase/database-types': 1.0.20 - '@firebase/logger': 0.5.1 - '@firebase/util': 1.15.1 - tslib: 2.8.1 - - '@firebase/database-types@1.0.20': - dependencies: - '@firebase/app-types': 0.9.5 - '@firebase/util': 1.15.1 - - '@firebase/database@1.1.3': - dependencies: - '@firebase/app-check-interop-types': 0.3.4 - '@firebase/auth-interop-types': 0.2.5 - '@firebase/component': 0.7.3 - '@firebase/logger': 0.5.1 - '@firebase/util': 1.15.1 - faye-websocket: 0.11.4 - tslib: 2.8.1 - - '@firebase/logger@0.5.1': - dependencies: - tslib: 2.8.1 - - '@firebase/util@1.15.1': - dependencies: - tslib: 2.8.1 - - '@google-cloud/firestore@8.6.0': - dependencies: - '@opentelemetry/api': 1.9.1 - fast-deep-equal: 3.1.3 - functional-red-black-tree: 1.0.1 - google-gax: 5.0.7 - protobufjs: 7.6.4 - transitivePeerDependencies: - - supports-color - optional: true - - '@google-cloud/paginator@5.0.2': - dependencies: - arrify: 2.0.1 - extend: 3.0.2 - - '@google-cloud/paginator@6.0.2': - dependencies: - extend: 3.0.2 - - '@google-cloud/precise-date@5.0.1': {} - - '@google-cloud/projectify@4.0.0': {} - - '@google-cloud/projectify@5.0.1': {} - - '@google-cloud/promisify@4.0.0': {} - - '@google-cloud/promisify@5.0.1': {} - - '@google-cloud/pubsub@5.3.1': - dependencies: - '@google-cloud/paginator': 6.0.2 - '@google-cloud/precise-date': 5.0.1 - '@google-cloud/projectify': 5.0.1 - '@google-cloud/promisify': 5.0.1 - '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.39.0 - arrify: 2.0.1 - extend: 3.0.2 - google-auth-library: 10.9.0 - google-gax: 5.0.7 - google-logging-utils: 1.1.4 - heap-js: 2.7.1 - is-stream-ended: 0.1.4 - lodash.snakecase: 4.1.1 - long: 5.3.2 - p-defer: 3.0.0 - transitivePeerDependencies: - - supports-color - - '@google-cloud/secret-manager@6.2.0': - dependencies: - google-gax: 5.0.7 - transitivePeerDependencies: - - supports-color - - '@google-cloud/storage@7.21.0': - dependencies: - '@google-cloud/paginator': 5.0.2 - '@google-cloud/projectify': 4.0.0 - '@google-cloud/promisify': 4.0.0 - abort-controller: 3.0.0 - async-retry: 1.3.3 - duplexify: 4.1.3 - fast-xml-parser: 5.9.3 - gaxios: 6.7.1 - google-auth-library: 9.15.1 - html-entities: 2.6.0 - mime: 3.0.0 - p-limit: 3.1.0 - retry-request: 7.0.2 - teeny-request: 9.0.0 - transitivePeerDependencies: - - encoding - - supports-color - - '@grpc/grpc-js@1.14.4': - dependencies: - '@grpc/proto-loader': 0.8.1 - '@js-sdsl/ordered-map': 4.4.2 - - '@grpc/proto-loader@0.8.1': - dependencies: - lodash.camelcase: 4.3.0 - long: 5.3.2 - protobufjs: 7.6.4 - yargs: 17.7.3 - - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': + '@humanfs/node@0.16.8': dependencies: '@humanfs/core': 0.19.2 '@humanfs/types': 0.15.0 @@ -5769,15 +3192,6 @@ snapshots: '@ioredis/commands@1.10.0': {} - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5792,43 +3206,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@js-sdsl/ordered-map@4.4.2': {} - - '@jsep-plugin/assignment@1.3.0(jsep@1.4.0)': - dependencies: - jsep: 1.4.0 - - '@jsep-plugin/regex@1.0.4(jsep@1.4.0)': - dependencies: - jsep: 1.4.0 - - '@kubernetes/client-node@1.4.0': - dependencies: - '@types/js-yaml': 4.0.9 - '@types/node': 24.13.2 - '@types/node-fetch': 2.6.13 - '@types/stream-buffers': 3.0.8 - form-data: 4.0.6 - hpagent: 1.2.0 - isomorphic-ws: 5.0.0(ws@8.21.0) - js-yaml: 4.2.0 - jsonpath-plus: 10.4.0 - node-fetch: 2.7.0 - openid-client: 6.8.4 - rfc4648: 1.5.4 - socks-proxy-agent: 8.0.5 - stream-buffers: 3.0.3 - tar-fs: 3.1.2 - ws: 8.21.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - bufferutil - - encoding - - react-native-b4a - - supports-color - - utf-8-validate - '@launchdarkly/js-sdk-common@2.25.1': {} '@launchdarkly/js-server-sdk-common@2.19.2': @@ -5895,62 +3272,14 @@ snapshots: '@opentelemetry/api@1.9.1': {} - '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/semantic-conventions': 1.28.0 - - '@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/semantic-conventions': 1.41.1 - - '@opentelemetry/semantic-conventions@1.28.0': {} - - '@opentelemetry/semantic-conventions@1.39.0': {} - - '@opentelemetry/semantic-conventions@1.41.1': {} - - '@parse/node-apn@8.1.0': - dependencies: - debug: 4.4.3 - jsonwebtoken: 9.0.3 - node-forge: 1.4.0 - verror: 1.10.1 - transitivePeerDependencies: - - supports-color - '@pinojs/redact@0.4.0': {} - '@pkgjs/parseargs@0.11.0': - optional: true - '@posthog/core@1.37.3': dependencies: '@posthog/types': 1.391.1 '@posthog/types@1.391.1': {} - '@protobufjs/aspromise@1.1.2': {} - - '@protobufjs/base64@1.1.2': {} - - '@protobufjs/codegen@2.0.5': {} - - '@protobufjs/eventemitter@1.1.1': {} - - '@protobufjs/fetch@1.1.1': - dependencies: - '@protobufjs/aspromise': 1.1.2 - - '@protobufjs/float@1.0.2': {} - - '@protobufjs/path@1.1.2': {} - - '@protobufjs/pool@1.1.0': {} - - '@protobufjs/utf8@1.1.1': {} - '@rollup/rollup-android-arm-eabi@4.62.0': optional: true @@ -6002,429 +3331,88 @@ snapshots: '@rollup/rollup-linux-s390x-gnu@4.62.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.62.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.62.0': - optional: true - - '@rollup/rollup-openbsd-x64@4.62.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.62.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.62.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.62.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.62.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.62.0': - optional: true - - '@rtsao/scc@1.1.0': {} - - '@segment/analytics-core@1.8.3': - dependencies: - '@lukeed/uuid': 2.0.1 - '@segment/analytics-generic-utils': 1.2.0 - dset: 3.1.4 - tslib: 2.8.1 - - '@segment/analytics-generic-utils@1.2.0': - dependencies: - tslib: 2.8.1 - - '@segment/analytics-next@1.84.0': - dependencies: - '@lukeed/uuid': 2.0.1 - '@segment/analytics-core': 1.8.3 - '@segment/analytics-generic-utils': 1.2.0 - '@segment/analytics-page-tools': 1.0.0 - '@segment/analytics.js-video-plugins': 0.2.1 - '@segment/facade': 3.4.10 - dset: 3.1.4 - js-cookie: 3.0.1 - node-fetch: 2.7.0 - tslib: 2.8.1 - unfetch: 4.2.0 - transitivePeerDependencies: - - encoding - - '@segment/analytics-node@3.1.0': - dependencies: - '@lukeed/uuid': 2.0.1 - '@segment/analytics-core': 1.8.3 - '@segment/analytics-generic-utils': 1.2.0 - buffer: 6.0.3 - jose: 5.10.0 - tslib: 2.8.1 - - '@segment/analytics-page-tools@1.0.0': - dependencies: - tslib: 2.8.1 - - '@segment/analytics.js-video-plugins@0.2.1': - dependencies: - unfetch: 3.1.2 - - '@segment/facade@3.4.10': - dependencies: - '@segment/isodate-traverse': 1.1.1 - inherits: 2.0.4 - new-date: 1.0.3 - obj-case: 0.2.1 - - '@segment/isodate-traverse@1.1.1': - dependencies: - '@segment/isodate': 1.0.3 - - '@segment/isodate@1.0.3': {} - - '@sindresorhus/is@4.6.0': {} - - '@smithy/abort-controller@3.1.9': - dependencies: - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/chunked-blob-reader-native@3.0.1': - dependencies: - '@smithy/util-base64': 3.0.0 - tslib: 2.8.1 - - '@smithy/chunked-blob-reader@4.0.0': - dependencies: - tslib: 2.8.1 - - '@smithy/config-resolver@3.0.13': - dependencies: - '@smithy/node-config-provider': 3.1.12 - '@smithy/types': 3.7.2 - '@smithy/util-config-provider': 3.0.0 - '@smithy/util-middleware': 3.0.11 - tslib: 2.8.1 - - '@smithy/core@2.5.7': - dependencies: - '@smithy/middleware-serde': 3.0.11 - '@smithy/protocol-http': 4.1.8 - '@smithy/types': 3.7.2 - '@smithy/util-body-length-browser': 3.0.0 - '@smithy/util-middleware': 3.0.11 - '@smithy/util-stream': 3.3.4 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@3.2.8': - dependencies: - '@smithy/node-config-provider': 3.1.12 - '@smithy/property-provider': 3.1.11 - '@smithy/types': 3.7.2 - '@smithy/url-parser': 3.0.11 - tslib: 2.8.1 - - '@smithy/eventstream-codec@3.1.10': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 3.7.2 - '@smithy/util-hex-encoding': 3.0.0 - tslib: 2.8.1 - - '@smithy/eventstream-serde-browser@3.0.14': - dependencies: - '@smithy/eventstream-serde-universal': 3.0.13 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/eventstream-serde-config-resolver@3.0.11': - dependencies: - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/eventstream-serde-node@3.0.13': - dependencies: - '@smithy/eventstream-serde-universal': 3.0.13 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/eventstream-serde-universal@3.0.13': - dependencies: - '@smithy/eventstream-codec': 3.1.10 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/fetch-http-handler@4.1.3': - dependencies: - '@smithy/protocol-http': 4.1.8 - '@smithy/querystring-builder': 3.0.11 - '@smithy/types': 3.7.2 - '@smithy/util-base64': 3.0.0 - tslib: 2.8.1 - - '@smithy/hash-blob-browser@3.1.10': - dependencies: - '@smithy/chunked-blob-reader': 4.0.0 - '@smithy/chunked-blob-reader-native': 3.0.1 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/hash-node@3.0.11': - dependencies: - '@smithy/types': 3.7.2 - '@smithy/util-buffer-from': 3.0.0 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - - '@smithy/hash-stream-node@3.1.10': - dependencies: - '@smithy/types': 3.7.2 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - - '@smithy/invalid-dependency@3.0.11': - dependencies: - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/is-array-buffer@2.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/is-array-buffer@3.0.0': - dependencies: - tslib: 2.8.1 - - '@smithy/md5-js@3.0.11': - dependencies: - '@smithy/types': 3.7.2 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - - '@smithy/middleware-content-length@3.0.13': - dependencies: - '@smithy/protocol-http': 4.1.8 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/middleware-endpoint@3.2.8': - dependencies: - '@smithy/core': 2.5.7 - '@smithy/middleware-serde': 3.0.11 - '@smithy/node-config-provider': 3.1.12 - '@smithy/shared-ini-file-loader': 3.1.12 - '@smithy/types': 3.7.2 - '@smithy/url-parser': 3.0.11 - '@smithy/util-middleware': 3.0.11 - tslib: 2.8.1 - - '@smithy/middleware-retry@3.0.34': - dependencies: - '@smithy/node-config-provider': 3.1.12 - '@smithy/protocol-http': 4.1.8 - '@smithy/service-error-classification': 3.0.11 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - '@smithy/util-middleware': 3.0.11 - '@smithy/util-retry': 3.0.11 - tslib: 2.8.1 - uuid: 9.0.1 - - '@smithy/middleware-serde@3.0.11': - dependencies: - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/middleware-stack@3.0.11': - dependencies: - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/node-config-provider@3.1.12': - dependencies: - '@smithy/property-provider': 3.1.11 - '@smithy/shared-ini-file-loader': 3.1.12 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/node-http-handler@3.3.3': - dependencies: - '@smithy/abort-controller': 3.1.9 - '@smithy/protocol-http': 4.1.8 - '@smithy/querystring-builder': 3.0.11 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/property-provider@3.1.11': - dependencies: - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/protocol-http@4.1.8': - dependencies: - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/querystring-builder@3.0.11': - dependencies: - '@smithy/types': 3.7.2 - '@smithy/util-uri-escape': 3.0.0 - tslib: 2.8.1 - - '@smithy/querystring-parser@3.0.11': - dependencies: - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/service-error-classification@3.0.11': - dependencies: - '@smithy/types': 3.7.2 - - '@smithy/shared-ini-file-loader@3.1.12': - dependencies: - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/signature-v4@4.2.4': - dependencies: - '@smithy/is-array-buffer': 3.0.0 - '@smithy/protocol-http': 4.1.8 - '@smithy/types': 3.7.2 - '@smithy/util-hex-encoding': 3.0.0 - '@smithy/util-middleware': 3.0.11 - '@smithy/util-uri-escape': 3.0.0 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - - '@smithy/smithy-client@3.7.0': - dependencies: - '@smithy/core': 2.5.7 - '@smithy/middleware-endpoint': 3.2.8 - '@smithy/middleware-stack': 3.0.11 - '@smithy/protocol-http': 4.1.8 - '@smithy/types': 3.7.2 - '@smithy/util-stream': 3.3.4 - tslib: 2.8.1 - - '@smithy/types@3.7.2': - dependencies: - tslib: 2.8.1 - - '@smithy/url-parser@3.0.11': - dependencies: - '@smithy/querystring-parser': 3.0.11 - '@smithy/types': 3.7.2 - tslib: 2.8.1 - - '@smithy/util-base64@3.0.0': - dependencies: - '@smithy/util-buffer-from': 3.0.0 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - - '@smithy/util-body-length-browser@3.0.0': - dependencies: - tslib: 2.8.1 + '@rollup/rollup-linux-x64-gnu@4.62.0': + optional: true - '@smithy/util-body-length-node@3.0.0': - dependencies: - tslib: 2.8.1 + '@rollup/rollup-linux-x64-musl@4.62.0': + optional: true - '@smithy/util-buffer-from@2.2.0': - dependencies: - '@smithy/is-array-buffer': 2.2.0 - tslib: 2.8.1 + '@rollup/rollup-openbsd-x64@4.62.0': + optional: true - '@smithy/util-buffer-from@3.0.0': - dependencies: - '@smithy/is-array-buffer': 3.0.0 - tslib: 2.8.1 + '@rollup/rollup-openharmony-arm64@4.62.0': + optional: true - '@smithy/util-config-provider@3.0.0': - dependencies: - tslib: 2.8.1 + '@rollup/rollup-win32-arm64-msvc@4.62.0': + optional: true - '@smithy/util-defaults-mode-browser@3.0.34': - dependencies: - '@smithy/property-provider': 3.1.11 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - bowser: 2.14.1 - tslib: 2.8.1 + '@rollup/rollup-win32-ia32-msvc@4.62.0': + optional: true - '@smithy/util-defaults-mode-node@3.0.34': - dependencies: - '@smithy/config-resolver': 3.0.13 - '@smithy/credential-provider-imds': 3.2.8 - '@smithy/node-config-provider': 3.1.12 - '@smithy/property-provider': 3.1.11 - '@smithy/smithy-client': 3.7.0 - '@smithy/types': 3.7.2 - tslib: 2.8.1 + '@rollup/rollup-win32-x64-gnu@4.62.0': + optional: true - '@smithy/util-endpoints@2.1.7': - dependencies: - '@smithy/node-config-provider': 3.1.12 - '@smithy/types': 3.7.2 - tslib: 2.8.1 + '@rollup/rollup-win32-x64-msvc@4.62.0': + optional: true - '@smithy/util-hex-encoding@3.0.0': - dependencies: - tslib: 2.8.1 + '@rtsao/scc@1.1.0': {} - '@smithy/util-middleware@3.0.11': + '@segment/analytics-core@1.8.3': dependencies: - '@smithy/types': 3.7.2 + '@lukeed/uuid': 2.0.1 + '@segment/analytics-generic-utils': 1.2.0 + dset: 3.1.4 tslib: 2.8.1 - '@smithy/util-retry@3.0.11': + '@segment/analytics-generic-utils@1.2.0': dependencies: - '@smithy/service-error-classification': 3.0.11 - '@smithy/types': 3.7.2 tslib: 2.8.1 - '@smithy/util-stream@3.3.4': + '@segment/analytics-next@1.84.0': dependencies: - '@smithy/fetch-http-handler': 4.1.3 - '@smithy/node-http-handler': 3.3.3 - '@smithy/types': 3.7.2 - '@smithy/util-base64': 3.0.0 - '@smithy/util-buffer-from': 3.0.0 - '@smithy/util-hex-encoding': 3.0.0 - '@smithy/util-utf8': 3.0.0 + '@lukeed/uuid': 2.0.1 + '@segment/analytics-core': 1.8.3 + '@segment/analytics-generic-utils': 1.2.0 + '@segment/analytics-page-tools': 1.0.0 + '@segment/analytics.js-video-plugins': 0.2.1 + '@segment/facade': 3.4.10 + dset: 3.1.4 + js-cookie: 3.0.1 + node-fetch: 2.7.0 tslib: 2.8.1 + unfetch: 4.2.0 + transitivePeerDependencies: + - encoding - '@smithy/util-uri-escape@3.0.0': + '@segment/analytics-node@3.1.0': dependencies: + '@lukeed/uuid': 2.0.1 + '@segment/analytics-core': 1.8.3 + '@segment/analytics-generic-utils': 1.2.0 + buffer: 6.0.3 + jose: 5.10.0 tslib: 2.8.1 - '@smithy/util-utf8@2.3.0': + '@segment/analytics-page-tools@1.0.0': dependencies: - '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@3.0.0': + '@segment/analytics.js-video-plugins@0.2.1': dependencies: - '@smithy/util-buffer-from': 3.0.0 - tslib: 2.8.1 + unfetch: 3.1.2 - '@smithy/util-waiter@3.2.0': + '@segment/facade@3.4.10': dependencies: - '@smithy/abort-controller': 3.1.9 - '@smithy/types': 3.7.2 - tslib: 2.8.1 + '@segment/isodate-traverse': 1.1.1 + inherits: 2.0.4 + new-date: 1.0.3 + obj-case: 0.2.1 - '@swc/helpers@0.5.23': + '@segment/isodate-traverse@1.1.1': dependencies: - tslib: 2.8.1 + '@segment/isodate': 1.0.3 - '@szmarczak/http-timer@4.0.6': - dependencies: - defer-to-connect: 2.0.1 + '@segment/isodate@1.0.3': {} '@tapico/node-openfeature-posthog@1.1.4(@opentelemetry/api@1.9.1)': dependencies: @@ -6436,8 +3424,6 @@ snapshots: - debug - supports-color - '@tootallnate/once@2.0.1': {} - '@turbo/darwin-64@2.9.18': optional: true @@ -6456,81 +3442,25 @@ snapshots: '@turbo/windows-arm64@2.9.18': optional: true - '@types/cacheable-request@6.0.3': - dependencies: - '@types/http-cache-semantics': 4.2.0 - '@types/keyv': 3.1.4 - '@types/node': 22.19.21 - '@types/responselike': 1.0.3 - - '@types/caseless@0.12.5': {} - - '@types/command-line-args@5.2.3': {} - - '@types/command-line-usage@5.0.4': {} - '@types/estree@1.0.9': {} - '@types/http-cache-semantics@4.2.0': {} - - '@types/js-yaml@4.0.9': {} - '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} - '@types/jsonwebtoken@9.0.10': - dependencies: - '@types/ms': 2.1.0 - '@types/node': 22.19.21 - - '@types/keyv@3.1.4': - dependencies: - '@types/node': 22.19.21 - - '@types/ms@2.1.0': {} - - '@types/node-fetch@2.6.13': - dependencies: - '@types/node': 22.19.21 - form-data: 4.0.6 - '@types/node@12.20.55': {} '@types/node@22.19.21': dependencies: undici-types: 6.21.0 - '@types/node@24.13.2': - dependencies: - undici-types: 7.18.2 - '@types/qrcode@1.5.5': dependencies: '@types/node': 22.19.21 - '@types/request@2.48.13': - dependencies: - '@types/caseless': 0.12.5 - '@types/node': 22.19.21 - '@types/tough-cookie': 4.0.5 - form-data: 2.5.6 - - '@types/responselike@1.0.3': - dependencies: - '@types/node': 22.19.21 - - '@types/stream-buffers@3.0.8': - dependencies: - '@types/node': 22.19.21 - - '@types/tough-cookie@4.0.5': {} - '@types/trusted-types@2.0.7': optional: true - '@types/uuid@9.0.8': {} - '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -6662,22 +3592,6 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 - ably@2.23.0: - dependencies: - '@ably/msgpack-js': 0.4.1 - dequal: 2.0.3 - fastestsmallesttextencoderdecoder: 1.0.22 - got: 11.8.6 - ulid: 2.4.0 - ws: 8.21.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 - acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -6699,61 +3613,24 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - algoliasearch@5.55.0: - dependencies: - '@algolia/abtesting': 1.21.0 - '@algolia/client-abtesting': 5.55.0 - '@algolia/client-analytics': 5.55.0 - '@algolia/client-common': 5.55.0 - '@algolia/client-insights': 5.55.0 - '@algolia/client-personalization': 5.55.0 - '@algolia/client-query-suggestions': 5.55.0 - '@algolia/client-search': 5.55.0 - '@algolia/ingestion': 1.55.0 - '@algolia/monitoring': 1.55.0 - '@algolia/recommend': 5.55.0 - '@algolia/requester-browser-xhr': 5.55.0 - '@algolia/requester-fetch': 5.55.0 - '@algolia/requester-node-http': 5.55.0 - ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - ansi-styles@6.2.3: {} - any-promise@1.3.0: {} anynum@1.0.1: {} - apache-arrow@21.1.0: - dependencies: - '@swc/helpers': 0.5.23 - '@types/command-line-args': 5.2.3 - '@types/command-line-usage': 5.0.4 - '@types/node': 24.13.2 - command-line-args: 6.0.2 - command-line-usage: 7.0.4 - flatbuffers: 25.9.23 - json-bignum: 0.0.3 - tslib: 2.8.1 - transitivePeerDependencies: - - '@75lb/nature' - argparse@1.0.10: dependencies: sprintf-js: 1.0.3 argparse@2.0.1: {} - array-back@6.2.3: {} - array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -6806,18 +3683,10 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 - arrify@2.0.1: {} - - assert-plus@1.0.0: {} - assertion-error@2.0.1: {} async-function@1.0.0: {} - async-retry@1.3.3: - dependencies: - retry: 0.13.1 - asynckit@0.4.0: {} atomic-sleep@1.0.0: {} @@ -6836,71 +3705,21 @@ snapshots: - debug - supports-color - b4a@1.8.1: {} - balanced-match@1.0.2: {} balanced-match@4.0.4: {} - bare-events@2.9.1: {} - - bare-fs@4.7.2: - dependencies: - bare-events: 2.9.1 - bare-path: 3.0.1 - bare-stream: 2.13.3(bare-events@2.9.1) - bare-url: 2.4.5 - fast-fifo: 1.3.2 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - - bare-os@3.9.1: {} - - bare-path@3.0.1: - dependencies: - bare-os: 3.9.1 - - bare-stream@2.13.3(bare-events@2.9.1): - dependencies: - b4a: 1.8.1 - streamx: 2.28.0 - teex: 1.0.1 - optionalDependencies: - bare-events: 2.9.1 - transitivePeerDependencies: - - react-native-b4a - - bare-url@2.4.5: - dependencies: - bare-path: 3.0.1 - - base64-js@1.0.2: {} - base64-js@1.5.1: {} better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 - bignumber.js@9.3.1: {} - - bops@1.0.1: - dependencies: - base64-js: 1.0.2 - to-utf8: 0.0.1 - - bowser@2.14.1: {} - brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.1: - dependencies: - balanced-match: 1.0.2 - brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -6909,13 +3728,6 @@ snapshots: dependencies: fill-range: 7.1.1 - buffer-equal-constant-time@1.0.1: {} - - buffer@5.6.0: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -6928,18 +3740,6 @@ snapshots: cac@6.7.14: {} - cacheable-lookup@5.0.4: {} - - cacheable-request@7.0.4: - dependencies: - clone-response: 1.0.3 - get-stream: 5.2.0 - http-cache-semantics: 4.2.0 - keyv: 4.5.4 - lowercase-keys: 2.0.0 - normalize-url: 6.1.0 - responselike: 2.0.1 - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -6969,10 +3769,6 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 - chalk-template@0.4.0: - dependencies: - chalk: 4.1.2 - chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -6992,16 +3788,6 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 6.2.0 - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - clone-response@1.0.3: - dependencies: - mimic-response: 1.0.1 - cluster-key-slot@1.1.1: {} color-convert@2.0.1: @@ -7014,20 +3800,6 @@ snapshots: dependencies: delayed-stream: 1.0.0 - command-line-args@6.0.2: - dependencies: - array-back: 6.2.3 - find-replace: 5.0.2 - lodash.camelcase: 4.3.0 - typical: 7.3.0 - - command-line-usage@7.0.4: - dependencies: - array-back: 6.2.3 - chalk-template: 0.4.0 - table-layout: 4.1.1 - typical: 7.3.0 - commander@4.1.1: {} concat-map@0.0.1: {} @@ -7038,16 +3810,12 @@ snapshots: core-js@3.49.0: {} - core-util-is@1.0.2: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - data-uri-to-buffer@4.0.1: {} - data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -7076,16 +3844,10 @@ snapshots: decamelize@1.2.0: {} - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - deep-eql@5.0.2: {} deep-is@0.1.4: {} - defer-to-connect@2.0.1: {} - define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -7102,8 +3864,6 @@ snapshots: denque@2.1.0: {} - dequal@2.0.3: {} - detect-indent@6.1.0: {} dijkstrajs@1.0.3: {} @@ -7128,27 +3888,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - duplexify@4.1.3: - dependencies: - end-of-stream: 1.4.5 - inherits: 2.0.4 - readable-stream: 3.6.2 - stream-shift: 1.0.3 - - eastasianwidth@0.2.0: {} - - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -7302,8 +4043,6 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 - escalade@3.2.0: {} - escape-string-regexp@4.0.0: {} eslint-import-resolver-node@0.3.10: @@ -7427,30 +4166,12 @@ snapshots: esutils@2.0.3: {} - event-target-shim@5.0.1: {} - - events-universal@1.0.1: - dependencies: - bare-events: 2.9.1 - transitivePeerDependencies: - - bare-abort-controller - - events@3.3.0: {} - expect-type@1.3.0: {} - extend@3.0.2: {} - extendable-error@0.1.7: {} - extsprintf@1.4.1: {} - - farmhash-modern@1.1.0: {} - fast-deep-equal@3.1.3: {} - fast-fifo@1.3.2: {} - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7468,10 +4189,6 @@ snapshots: path-expression-matcher: 1.6.0 xml-naming: 0.1.0 - fast-xml-parser@4.4.1: - dependencies: - strnum: 1.1.2 - fast-xml-parser@5.9.3: dependencies: '@nodable/entities': 2.2.0 @@ -7481,25 +4198,14 @@ snapshots: strnum: 2.4.1 xml-naming: 0.1.0 - fastestsmallesttextencoderdecoder@1.0.22: {} - fastq@1.20.1: dependencies: reusify: 1.1.0 - faye-websocket@0.11.4: - dependencies: - websocket-driver: 0.7.5 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 - fetch-blob@3.2.0: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 3.3.3 - fflate@0.4.8: {} file-entry-cache@8.0.0: @@ -7510,8 +4216,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - find-replace@5.0.2: {} - find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -7522,23 +4226,6 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - firebase-admin@14.1.0: - dependencies: - '@fastify/busboy': 3.2.0 - '@firebase/database-compat': 2.1.4 - '@firebase/database-types': 1.0.20 - farmhash-modern: 1.1.0 - fast-deep-equal: 3.1.3 - google-auth-library: 10.9.0 - jsonwebtoken: 9.0.3 - jwks-rsa: 4.1.0 - optionalDependencies: - '@google-cloud/firestore': 8.6.0 - '@google-cloud/storage': 7.21.0 - transitivePeerDependencies: - - encoding - - supports-color - fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -7550,8 +4237,6 @@ snapshots: flatted: 3.4.2 keyv: 4.5.4 - flatbuffers@25.9.23: {} - flatted@3.4.2: {} follow-redirects@1.16.0: {} @@ -7560,20 +4245,6 @@ snapshots: dependencies: is-callable: 1.2.7 - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - form-data@2.5.6: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - safe-buffer: 5.2.1 - form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -7582,10 +4253,6 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 - formdata-polyfill@4.0.10: - dependencies: - fetch-blob: 3.2.0 - fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -7615,64 +4282,8 @@ snapshots: is-callable: 1.2.7 is-document.all: 1.0.0 - functional-red-black-tree@1.0.1: - optional: true - functions-have-names@1.2.3: {} - gaxios@6.7.1: - dependencies: - extend: 3.0.2 - https-proxy-agent: 7.0.6 - is-stream: 2.0.1 - node-fetch: 2.7.0 - uuid: 9.0.1 - transitivePeerDependencies: - - encoding - - supports-color - - gaxios@7.1.3: - dependencies: - extend: 3.0.2 - https-proxy-agent: 7.0.6 - node-fetch: 3.3.2 - rimraf: 5.0.10 - transitivePeerDependencies: - - supports-color - - gaxios@7.1.5: - dependencies: - extend: 3.0.2 - https-proxy-agent: 7.0.6 - node-fetch: 3.3.2 - transitivePeerDependencies: - - supports-color - - gcp-metadata@6.1.1: - dependencies: - gaxios: 6.7.1 - google-logging-utils: 0.0.2 - json-bigint: 1.0.0 - transitivePeerDependencies: - - encoding - - supports-color - - gcp-metadata@8.1.2: - dependencies: - gaxios: 7.1.5 - google-logging-utils: 1.1.4 - json-bigint: 1.0.0 - transitivePeerDependencies: - - supports-color - - gcp-metadata@8.1.3: - dependencies: - gaxios: 7.1.3 - google-logging-utils: 1.1.3 - json-bigint: 1.0.0 - transitivePeerDependencies: - - supports-color - generator-function@2.0.1: {} get-caller-file@2.0.5: {} @@ -7695,10 +4306,6 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 - get-stream@5.2.0: - dependencies: - pump: 3.0.4 - get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -7713,15 +4320,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - globals@14.0.0: {} globalthis@1.0.4: @@ -7738,96 +4336,10 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 - google-auth-library@10.5.0: - dependencies: - base64-js: 1.5.1 - ecdsa-sig-formatter: 1.0.11 - gaxios: 7.1.5 - gcp-metadata: 8.1.3 - google-logging-utils: 1.1.4 - gtoken: 8.0.0 - jws: 4.0.1 - transitivePeerDependencies: - - supports-color - - google-auth-library@10.9.0: - dependencies: - base64-js: 1.5.1 - ecdsa-sig-formatter: 1.0.11 - gaxios: 7.1.5 - gcp-metadata: 8.1.2 - google-logging-utils: 1.1.3 - jws: 4.0.1 - transitivePeerDependencies: - - supports-color - - google-auth-library@9.15.1: - dependencies: - base64-js: 1.5.1 - ecdsa-sig-formatter: 1.0.11 - gaxios: 6.7.1 - gcp-metadata: 6.1.1 - gtoken: 7.1.0 - jws: 4.0.1 - transitivePeerDependencies: - - encoding - - supports-color - - google-gax@5.0.7: - dependencies: - '@grpc/grpc-js': 1.14.4 - '@grpc/proto-loader': 0.8.1 - duplexify: 4.1.3 - google-auth-library: 10.5.0 - google-logging-utils: 1.1.3 - node-fetch: 3.3.2 - object-hash: 3.0.0 - proto3-json-serializer: 3.0.4 - protobufjs: 7.6.4 - retry-request: 8.0.3 - rimraf: 5.0.10 - transitivePeerDependencies: - - supports-color - - google-logging-utils@0.0.2: {} - - google-logging-utils@1.1.3: {} - - google-logging-utils@1.1.4: {} - gopd@1.2.0: {} - got@11.8.6: - dependencies: - '@sindresorhus/is': 4.6.0 - '@szmarczak/http-timer': 4.0.6 - '@types/cacheable-request': 6.0.3 - '@types/responselike': 1.0.3 - cacheable-lookup: 5.0.4 - cacheable-request: 7.0.4 - decompress-response: 6.0.0 - http2-wrapper: 1.0.3 - lowercase-keys: 2.0.0 - p-cancelable: 2.1.1 - responselike: 2.0.1 - graceful-fs@4.2.11: {} - gtoken@7.1.0: - dependencies: - gaxios: 6.7.1 - jws: 4.0.1 - transitivePeerDependencies: - - encoding - - supports-color - - gtoken@8.0.0: - dependencies: - gaxios: 7.1.5 - jws: 4.0.1 - transitivePeerDependencies: - - supports-color - has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -7850,36 +4362,6 @@ snapshots: dependencies: function-bind: 1.1.2 - heap-js@2.7.1: {} - - hpagent@1.2.0: {} - - html-entities@2.6.0: {} - - http-cache-semantics@4.2.0: {} - - http-parser-js@0.5.10: {} - - http-proxy-agent@5.0.0: - dependencies: - '@tootallnate/once': 2.0.1 - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - http2-wrapper@1.0.3: - dependencies: - quick-lru: 5.1.1 - resolve-alpn: 1.2.1 - https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -7933,8 +4415,6 @@ snapshots: transitivePeerDependencies: - supports-color - ip-address@10.2.0: {} - is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -7949,8 +4429,6 @@ snapshots: has-tostringtag: 1.0.2 safe-regex-test: 1.1.0 - is-base64@1.1.0: {} - is-bigint@1.1.0: dependencies: has-bigints: 1.1.0 @@ -8025,10 +4503,6 @@ snapshots: dependencies: call-bound: 1.0.4 - is-stream-ended@0.1.4: {} - - is-stream@2.0.1: {} - is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -8067,20 +4541,8 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@5.0.0(ws@8.21.0): - dependencies: - ws: 8.21.0 - - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jose@5.10.0: {} - jose@6.2.3: {} - joycon@3.1.1: {} js-cookie@3.0.1: {} @@ -8094,14 +4556,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsep@1.4.0: {} - - json-bigint@1.0.0: - dependencies: - bignumber.js: 9.3.1 - - json-bignum@0.0.3: {} - json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -8116,49 +4570,6 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsonpath-plus@10.4.0: - dependencies: - '@jsep-plugin/assignment': 1.3.0(jsep@1.4.0) - '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) - jsep: 1.4.0 - - jsonwebtoken@9.0.3: - dependencies: - jws: 4.0.1 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.8.4 - - jwa@2.0.1: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - - jwks-rsa@4.1.0: - dependencies: - '@types/jsonwebtoken': 9.0.10 - debug: 4.4.3 - jose: 6.2.3 - limiter: 1.1.5 - lru-cache: 11.5.1 - lru-memoizer: 3.0.0 - transitivePeerDependencies: - - supports-color - - jws@4.0.1: - dependencies: - jwa: 2.0.1 - safe-buffer: 5.2.1 - - kafkajs@2.2.4: {} - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -8172,8 +4583,6 @@ snapshots: lilconfig@3.1.3: {} - limiter@1.1.5: {} - lines-and-columns@1.2.4: {} load-tsconfig@0.2.5: {} @@ -8186,51 +4595,16 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash.camelcase@4.3.0: {} - - lodash.clonedeep@4.5.0: {} - - lodash.includes@4.3.0: {} - - lodash.isboolean@3.0.3: {} - - lodash.isinteger@4.0.4: {} - - lodash.isnumber@3.0.3: {} - - lodash.isplainobject@4.0.6: {} - - lodash.isstring@4.0.1: {} - lodash.merge@4.6.2: {} - lodash.once@4.1.1: {} - - lodash.snakecase@4.1.1: {} - lodash.startcase@4.4.0: {} - loglevel@1.9.2: {} - - long@5.3.2: {} - loupe@3.2.1: {} - lowercase-keys@2.0.0: {} - - lru-cache@10.4.3: {} - - lru-cache@11.5.1: {} - lru-cache@6.0.0: dependencies: yallist: 4.0.0 - lru-memoizer@3.0.0: - dependencies: - lodash.clonedeep: 4.5.0 - lru-cache: 11.5.1 - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8250,12 +4624,6 @@ snapshots: dependencies: mime-db: 1.52.0 - mime@3.0.0: {} - - mimic-response@1.0.1: {} - - mimic-response@3.1.0: {} - minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -8264,14 +4632,8 @@ snapshots: dependencies: brace-expansion: 1.1.15 - minimatch@9.0.9: - dependencies: - brace-expansion: 2.1.1 - minimist@1.2.8: {} - minipass@7.1.3: {} - mlly@1.8.2: dependencies: acorn: 8.17.0 @@ -8299,8 +4661,6 @@ snapshots: dependencies: '@segment/isodate': 1.0.3 - node-domexception@1.0.0: {} - node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -8312,24 +4672,10 @@ snapshots: dependencies: whatwg-url: 5.0.0 - node-fetch@3.3.2: - dependencies: - data-uri-to-buffer: 4.0.1 - fetch-blob: 3.2.0 - formdata-polyfill: 4.0.10 - - node-forge@1.4.0: {} - - normalize-url@6.1.0: {} - - oauth4webapi@3.8.6: {} - obj-case@0.2.1: {} object-assign@4.1.1: {} - object-hash@3.0.0: {} - object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -8372,15 +4718,6 @@ snapshots: on-exit-leak-free@2.1.2: {} - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - openid-client@6.8.4: - dependencies: - jose: 6.2.3 - oauth4webapi: 3.8.6 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8398,10 +4735,6 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - p-cancelable@2.1.1: {} - - p-defer@3.0.0: {} - p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -8426,8 +4759,6 @@ snapshots: p-try@2.2.0: {} - package-json-from-dist@1.0.1: {} - package-manager-detector@0.2.11: dependencies: quansync: 0.2.11 @@ -8444,11 +4775,6 @@ snapshots: path-parse@1.0.7: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.3 - path-type@4.0.0: {} pathe@1.1.2: {} @@ -8532,13 +4858,6 @@ snapshots: dependencies: '@posthog/core': 1.37.3 - postmark@4.0.7: - dependencies: - axios: 1.18.0 - transitivePeerDependencies: - - debug - - supports-color - preact@10.29.2: {} prelude-ls@1.2.1: {} @@ -8549,44 +4868,10 @@ snapshots: process-warning@5.0.0: {} - proto3-json-serializer@3.0.4: - dependencies: - protobufjs: 7.6.4 - - protobufjs@7.6.4: - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.5 - '@protobufjs/eventemitter': 1.1.1 - '@protobufjs/fetch': 1.1.1 - '@protobufjs/float': 1.0.2 - '@protobufjs/path': 1.1.2 - '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.1 - '@types/node': 22.19.21 - long: 5.3.2 - proxy-from-env@2.1.0: {} - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - punycode@2.3.1: {} - pusher@5.3.4: - dependencies: - '@types/node-fetch': 2.6.13 - abort-controller: 3.0.0 - is-base64: 1.1.0 - node-fetch: 2.7.0 - tweetnacl: 1.0.3 - tweetnacl-util: 0.15.1 - transitivePeerDependencies: - - encoding - qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3 @@ -8601,8 +4886,6 @@ snapshots: quick-format-unescaped@4.0.4: {} - quick-lru@5.1.1: {} - read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -8610,12 +4893,6 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - readdirp@4.1.2: {} real-require@0.2.0: {} @@ -8650,8 +4927,6 @@ snapshots: require-main-filename@2.0.0: {} - resolve-alpn@1.2.1: {} - resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -8665,36 +4940,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - responselike@2.0.1: - dependencies: - lowercase-keys: 2.0.0 - - retry-request@7.0.2: - dependencies: - '@types/request': 2.48.13 - extend: 3.0.2 - teeny-request: 9.0.0 - transitivePeerDependencies: - - encoding - - supports-color - - retry-request@8.0.3: - dependencies: - extend: 3.0.2 - teeny-request: 10.1.3 - transitivePeerDependencies: - - supports-color - - retry@0.13.1: {} - reusify@1.1.0: {} - rfc4648@1.5.4: {} - - rimraf@5.0.10: - dependencies: - glob: 10.5.0 - rollup@4.62.0: dependencies: '@types/estree': 1.0.9 @@ -8738,8 +4985,6 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 - safe-buffer@5.2.1: {} - safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -8755,8 +5000,6 @@ snapshots: safer-buffer@2.1.2: {} - secure-json-parse@3.0.2: {} - semver@6.3.1: {} semver@7.5.4: @@ -8829,23 +5072,8 @@ snapshots: slash@3.0.0: {} - smart-buffer@4.2.0: {} - smol-toml@1.7.0: {} - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.9 - transitivePeerDependencies: - - supports-color - - socks@2.8.9: - dependencies: - ip-address: 10.2.0 - smart-buffer: 4.2.0 - sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -8874,40 +5102,12 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - stream-browserify@3.0.0: - dependencies: - inherits: 2.0.4 - readable-stream: 3.6.2 - - stream-buffers@3.0.3: {} - - stream-events@1.0.5: - dependencies: - stubs: 3.0.0 - - stream-shift@1.0.3: {} - - streamx@2.28.0: - dependencies: - events-universal: 1.0.1 - fast-fifo: 1.3.2 - text-decoder: 1.2.7 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - string.prototype.trim@1.2.11: dependencies: call-bind: 1.0.9 @@ -8932,30 +5132,18 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} - strnum@1.1.2: {} - strnum@2.4.1: dependencies: anynum: 1.0.1 - stubs@3.0.0: {} - sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -8972,69 +5160,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - table-layout@4.1.1: - dependencies: - array-back: 6.2.3 - wordwrapjs: 5.1.1 - - tar-fs@3.1.2: - dependencies: - pump: 3.0.4 - tar-stream: 3.2.0 - optionalDependencies: - bare-fs: 4.7.2 - bare-path: 3.0.1 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - tar-stream@3.2.0: - dependencies: - b4a: 1.8.1 - bare-fs: 4.7.2 - fast-fifo: 1.3.2 - streamx: 2.28.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - teeny-request@10.1.3: - dependencies: - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - node-fetch: 3.3.2 - stream-events: 1.0.5 - transitivePeerDependencies: - - supports-color - - teeny-request@9.0.0: - dependencies: - http-proxy-agent: 5.0.0 - https-proxy-agent: 5.0.1 - node-fetch: 2.7.0 - stream-events: 1.0.5 - uuid: 9.0.1 - transitivePeerDependencies: - - encoding - - supports-color - - teex@1.0.1: - dependencies: - streamx: 2.28.0 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - term-size@2.2.1: {} - text-decoder@1.2.7: - dependencies: - b4a: 1.8.1 - transitivePeerDependencies: - - react-native-b4a - thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -9066,8 +5193,6 @@ snapshots: dependencies: is-number: 7.0.0 - to-utf8@0.0.1: {} - tr46@0.0.3: {} tree-kill@1.2.2: {} @@ -9124,10 +5249,6 @@ snapshots: '@turbo/windows-64': 2.9.18 '@turbo/windows-arm64': 2.9.18 - tweetnacl-util@0.15.1: {} - - tweetnacl@1.0.3: {} - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -9178,22 +5299,8 @@ snapshots: typescript@5.9.3: {} - typesense@3.0.6(@babel/runtime@7.29.7): - dependencies: - '@babel/runtime': 7.29.7 - axios: 1.18.0 - loglevel: 1.9.2 - tslib: 2.8.1 - transitivePeerDependencies: - - debug - - supports-color - - typical@7.3.0: {} - ufo@1.6.4: {} - ulid@2.4.0: {} - ulid@3.0.2: {} unbox-primitive@1.1.0: @@ -9205,10 +5312,6 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.18.2: {} - - undici@6.27.0: {} - unfetch@3.1.2: {} unfetch@4.2.0: {} @@ -9219,16 +5322,6 @@ snapshots: dependencies: punycode: 2.3.1 - util-deprecate@1.0.2: {} - - uuid@9.0.1: {} - - verror@1.10.1: - dependencies: - assert-plus: 1.0.0 - core-util-is: 1.0.2 - extsprintf: 1.4.1 - vite-node@2.1.9(@types/node@22.19.21): dependencies: cac: 6.7.14 @@ -9291,20 +5384,10 @@ snapshots: - supports-color - terser - web-streams-polyfill@3.3.3: {} - web-vitals@5.3.0: {} webidl-conversions@3.0.1: {} - websocket-driver@0.7.5: - dependencies: - http-parser-js: 0.5.10 - safe-buffer: 5.2.1 - websocket-extensions: 0.1.4 - - websocket-extensions@0.1.4: {} - whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -9364,36 +5447,16 @@ snapshots: word-wrap@1.2.5: {} - wordwrapjs@5.1.1: {} - wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 - - wrappy@1.0.2: {} - - ws@8.21.0: {} - xml-naming@0.1.0: {} y18n@4.0.3: {} - y18n@5.0.8: {} - yallist@4.0.0: {} yaml@2.9.0: {} @@ -9403,8 +5466,6 @@ snapshots: camelcase: 5.3.1 decamelize: 1.2.0 - yargs-parser@21.1.1: {} - yargs@15.4.1: dependencies: cliui: 6.0.0 @@ -9419,16 +5480,6 @@ snapshots: y18n: 4.0.3 yargs-parser: 18.1.3 - yargs@17.7.3: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - yocto-queue@0.1.0: {} zod@3.25.76: {}