@triargos/effect-procurat
v3.1.1
Published
Effect-based Procurat SDK
Readme
@triargos/effect-procurat
Effect-based SDK for the Procurat API.
Install
pnpm add @triargos/effect-procurat effecteffect@^4.0.0-beta.0 is a peer dependency.
On Effect v3
Import from the /v3 subpath and add @effect/platform:
pnpm add @triargos/effect-procurat effect@^3.18.5 @effect/platform@^0.92.1import { ProcuratClient } from '@triargos/effect-procurat/v3';
import { CreatePerson, Person } from '@triargos/effect-procurat/v3/schemas';
import type { ProcuratError } from '@triargos/effect-procurat/v3/errors';The subpath carries the same API, the same errors and the same schemas as the v4 entry
points. Two spelling differences follow Effect itself: the transport comes from
@effect/platform instead of effect/unstable/http, and a request schema such as
CreatePerson is a value plus a type rather than a class.
Every release ships both. The v3 build is generated from the v4 sources and blocks the
release if it does not typecheck and pass its tests — see v3/README.md.
Provide a transport
The SDK does not ship an HTTP transport. Pick one from effect/unstable/http and provide it
alongside the client layer — FetchHttpClient.layer works everywhere fetch does.
import { Effect, Layer, Redacted } from 'effect';
import { FetchHttpClient } from 'effect/unstable/http';
import { ProcuratClient } from '@triargos/effect-procurat';
const ProcuratLive = ProcuratClient.layer({
apiKey: Redacted.make(process.env.PROCURAT_API_KEY!),
baseUrl: 'https://procurat.example.com/api',
}).pipe(Layer.provide(FetchHttpClient.layer));Or read both from the environment (PROCURAT_API_KEY, PROCURAT_BASE_URL):
const ProcuratLive = ProcuratClient.layerConfig().pipe(Layer.provide(FetchHttpClient.layer));Use it
Every method takes a single params object and fails with ProcuratError.
import { Effect } from 'effect';
import { ProcuratClient } from '@triargos/effect-procurat';
const program = Effect.gen(function* () {
const procurat = yield* ProcuratClient;
const person = yield* procurat.person.findById({ id: 42 });
const optional = yield* procurat.person
.findById({ id: 99 })
.pipe(Effect.catchTag('ProcuratNotFoundError', () => Effect.succeed(null)));
yield* procurat.person.update({ person: { ...person, comment: 'synced' } });
return { person, optional };
});Because every operation shares one error union, a fan-out sync collects failures that are already
self-describing — each error carries the operation and endpoint it came from:
const results =
yield *
Effect.forEach(ids, (id) => procurat.person.findById({ id }), {
mode: 'either',
concurrency: 8,
});Errors
All failures are Data.TaggedError classes; catch them with Effect.catchTag.
| tag | cause | retried |
| ------------------------- | ---------------------------------- | ------- |
| ProcuratNotFoundError | 404 | no |
| ProcuratBadRequestError | 400, 409, 422 | no |
| ProcuratAuthError | 401, 403 | no |
| ProcuratUnavailableError | 5xx, unmapped status, no response | yes |
| ProcuratDecodeError | response shape drift | no |
Every error carries operation and endpoint. HTTP response failures also carry status, code,
and message. ProcuratBadRequestError carries the rejected payload for dead-lettering.
ProcuratUnavailableError.kind distinguishes server and transport failures, while
ProcuratDecodeError carries the raw body that failed to decode.
An error body that is not Procurat's { code, error } envelope — an HTML page from a reverse
proxy, say — still surfaces as a typed error, with code: null and the raw text as message.
Retries
ProcuratUnavailableError is retried 3 times with jittered exponential backoff starting at 200ms.
Procurat does not send Retry-After, so none is honoured.
Install ProcuratRetry to change the policy for every call:
import { Schedule } from 'effect';
import { ProcuratRetry } from '@triargos/effect-procurat';
const NoRetries = ProcuratRetry.layer({
while: () => false,
schedule: Schedule.forever,
times: 0,
});
const ProcuratLive = ProcuratClient.layer({ apiKey, baseUrl }).pipe(
Layer.provide(NoRetries),
Layer.provide(FetchHttpClient.layer),
);Dates
Every date field is an IsoDate: a YYYY-MM-DD string with no time and no zone. It is
display-ready, compares with ===, and sorts with <. Build one with IsoDate.make or, from a
Date, with IsoDate.fromDate — the zone is yours to pick, because a Date near midnight falls on
a different day in UTC than it does locally.
import { IsoDate } from '@triargos/effect-procurat';
yield * procurat.absence.create({
absence: {
personId: 42,
startDate: IsoDate.make('2024-05-01'),
endDate: IsoDate.fromDate(picker.value, 'local'),
// ...
},
});Procurat is moving from timestamps (2024-05-01T00:00:00.000Z) to date-only strings. Responses in
either format decode the same, so reads need no configuration. Writes do: if your installation still
runs the old API, ask for the old format.
const ProcuratLive = ProcuratClient.layer({ apiKey, baseUrl, dateFormat: 'timestamp' });The option defaults to 'iso-date' and disappears once every installation has moved.
If you do not know which format an installation wants, ask it. health.determineDateStyle()
reads the build number and answers 'iso-date' or 'timestamp':
const dateFormat = yield* procurat.health.determineDateStyle();This one is temporary too and goes away with the option.
Schemas
Response and request types live on @triargos/effect-procurat/schemas — Person, CreatePerson,
Address, Group, and so on. Request types are plain object shapes: pass an object literal, no
constructor call.
Health
procurat.health.get() reads GET /health — the build number, the database version, and what
the installation is doing right now.
const health = yield* procurat.health.get();
health.build; // 4711
health.productionVersion; // '2024.1'
health.databaseLocked; // falseThe lastUpdate* fields stay raw strings, because the API names no format for them.
The same service answers which date format the installation accepts on write — see Dates.
File downloads
file.download* answers with the content type alongside the bytes, because the endpoints stream
arbitrary files and nothing in the path tells you what you got.
const { contentType, stream } = yield* procurat.file.downloadPublicFile({ path: 'info/note.pdf' });
contentType; // 'application/pdf'
yield * Stream.run(stream, sink);When the installation sends no Content-Type, contentType is 'application/octet-stream' — what
HTTP already means by an unlabeled body, and what file.upload* sends when you name no type.
There is no fileName: these endpoints address files by path, so the basename of the path you
passed is the name.
File uploads
file.upload* buffers the whole stream into memory before sending it, because the endpoint takes
multipart and FormData needs a materialized blob. Size uploads accordingly.
