@softure-ai/core 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ Newest first. Each version lists what changed for an app that uses `@softure-ai/
4
4
  production, the version gets a line `verified in: <app>@<commit>` ([docs/05](../../docs/05-adoption-playbook.md),
5
5
  "Definition of done"). Versions before the first one below are described in their GitHub Releases (`core@x.y.z`).
6
6
 
7
+ ## 0.2.1
8
+
9
+ - New (#599): calendar periods and local wall-clock date-times next to calendar days.
10
+ - `CalendarPeriod` (`{ from, to }`, both ends included) and `calendarPeriodSchema`, which refuses `to` before
11
+ `from` on the path `["to"]` with `must not come before from`.
12
+ - `isDayInPeriod(day, period)`, `doPeriodsOverlap(a, b)` (one shared day overlaps, adjacent periods do not),
13
+ `countPeriodDays(period)` (1 for a single day).
14
+ - `LocalDateTime`, a branded `YYYY-MM-DDTHH:mm` string with no zone, made by `localDateTimeSchema` or
15
+ `isLocalDateTime(value)`; its day part goes through `isCalendarDay`, so `2026-02-30T10:00` is refused.
16
+ `getLocalDay(value)` returns the day.
17
+ - Migrate: an app that defined its own period or local date-time drops its schema, type and helpers and imports
18
+ these. Check two differences: overlap counts a single shared day, and a period's error sits on `to`. An app
19
+ that typed a local date-time as `string` passes values through `localDateTimeSchema` (or `isLocalDateTime`)
20
+ before using them as `LocalDateTime`.
21
+ - `requestJson({ url, fetch?, headers?, timeoutMs?, schema? })`: a JSON GET for third-party APIs (#598). A timeout
22
+ (default 10 000 ms), no redirect followed, `Accept: application/json` by default, and every failure returned as an
23
+ `HttpJsonFailure` with a code and the status, never the URL or the error text: `core.http_unreachable`,
24
+ `core.http_refused` (3xx, 401, 403), `core.http_rate_limited` (429), `core.http_unavailable` (5xx),
25
+ `core.http_unexpected_answer` (other status, not JSON, fails the schema). The body is `unknown` without a zod
26
+ `schema` and the schema's output with one. `toQueryString(record)` builds a query string, skipping `null` and
27
+ `undefined`. English copy for the five codes in `coreMessages` (`errors.http_*`).
28
+ - Migrate (optional): an app with its own request helper replaces it with `requestJson`, passes the same headers
29
+ and timeout, moves its response check into `schema`, and maps the five codes to its own codes and messages
30
+ (`result.error`, `result.status`). The helper's own timeout, redirect and error-scrubbing code can go.
31
+
7
32
  ## 0.2.0
8
33
 
9
34
  - **Breaking (#446):** English is the only built-in language.
package/README.md CHANGED
@@ -277,7 +277,7 @@ None. Tokens, slots and styles live in `@softure-ai/ui`.
277
277
  locale and reused. `locale` is any BCP 47 tag; a category the forms leave out falls back to `other`.
278
278
  - `getMessage(dictionary, "errors.unexpected")` reads a dotted path.
279
279
  - Core's own keys (`coreMessages`): `errors.database_failed`, `errors.unexpected`, the copy for
280
- the `core.database_failed` and `core.unexpected` codes.
280
+ the `core.database_failed` and `core.unexpected` codes, and `errors.http_*` for `requestJson`'s codes.
281
281
 
282
282
  **Errors as values.** `Result<T, E>` is `{ ok: true, value } | { ok: false, error }`, built with
283
283
  `ok(value)` / `ok()` and `err("module.code")`. Codes are namespaced by module id
@@ -305,6 +305,45 @@ if (plan.endedAt !== null) throw new PublicError("This plan has ended; pick anot
305
305
  `isPublicError` recognises one from another copy of core too (a registry symbol, not `instanceof`).
306
306
  `safeError` itself still answers `core.unexpected` for it, since callers switch on its two codes.
307
307
 
308
+ **External JSON APIs.** `requestJson({ url, fetch?, headers?, timeoutMs?, schema? })` GETs a third-party JSON API
309
+ and returns `Ok<value>` or an `HttpJsonFailure` (`{ ok: false, error, status }`). It sets the details a hand-written
310
+ helper tends to miss:
311
+
312
+ - a timeout, `AbortSignal.timeout(timeoutMs)` (default 10 000 ms), body included;
313
+ - `redirect: "manual"`, so a key in a header or the URL never follows a redirect to another host;
314
+ - `Accept: application/json` unless `headers` sets its own;
315
+ - every failure is a code plus the status (`null` when no response arrived), never the URL, a header or the caught
316
+ error's text (some APIs put the key in the URL, and a fetch error can quote it).
317
+
318
+ | Code | When |
319
+ |---|---|
320
+ | `core.http_unreachable` | network error, timeout, a body that breaks off |
321
+ | `core.http_refused` | 3xx, an opaque redirect, 401, 403 |
322
+ | `core.http_rate_limited` | 429 |
323
+ | `core.http_unavailable` | 5xx |
324
+ | `core.http_unexpected_answer` | any other non-2xx status, a body that is not JSON or fails the schema |
325
+
326
+ Without `schema` the value is `unknown`, to be narrowed at the boundary; with a zod schema it is the schema's
327
+ output. `fetch` defaults to the global one; tests pass a fake. `toQueryString(record)` builds the query part
328
+ (without `?`) from strings, numbers and booleans, leaving out `null` and `undefined`:
329
+
330
+ ```ts
331
+ import { err, requestJson, toQueryString } from "@softure-ai/core";
332
+ import { z } from "zod";
333
+
334
+ const rateSchema = z.object({ rates: z.array(z.object({ mid: z.number() })) });
335
+
336
+ const result = await requestJson({
337
+ url: `https://api.example.com/rates?${toQueryString({ currency, date, page: undefined })}`,
338
+ headers: { Authorization: `Bearer ${env.RATES_API_KEY}` },
339
+ timeoutMs: 5_000,
340
+ schema: rateSchema,
341
+ });
342
+ if (!result.ok) {
343
+ return err(result.error === "core.http_rate_limited" ? "rates.busy" : "rates.unavailable"); // the app's own codes
344
+ }
345
+ ```
346
+
308
347
  **Time.** Server code receives a `Clock` (`ModuleContext.clock`) instead of calling `new Date()`.
309
348
  `systemClock` is the real one; `createTestClock(start)` has `advance(ms)` and `set(date)`.
310
349
  A whole test run can also move to another day without code changes: `TEST_TODAY=YYYY-MM-DD` with the
@@ -335,6 +374,35 @@ or a fractional count.
335
374
 
336
375
  `calendarDaySchema` is `isCalendarDay` as a zod schema, for tool and form inputs that take a day.
337
376
 
377
+ **Calendar periods.** A `CalendarPeriod` is `{ from, to }`, two calendar days with **both ends included**:
378
+ one day is `{ from: d, to: d }`. `calendarPeriodSchema` parses one from input and refuses `to` before `from`
379
+ with the message `must not come before from` on the path `["to"]`, so a form shows it next to the end field;
380
+ a malformed day gets only its own `calendarDaySchema` issue.
381
+
382
+ | Function | Semantics |
383
+ | --- | --- |
384
+ | `isDayInPeriod(day, period)` | `day` lies in `period`, either end included. |
385
+ | `doPeriodsOverlap(a, b)` | The periods share at least one day: `12-01..12-31` and `12-31..01-05` overlap, `12-01..12-31` and `01-01..01-31` do not. |
386
+ | `countPeriodDays(period)` | Days in the period, both ends counted: 1 for a single day, 31 for December. |
387
+
388
+ They throw a `RangeError` for a malformed day or a period that ends before it starts.
389
+
390
+ ```ts
391
+ const input = z.object({ stay: calendarPeriodSchema }).parse(body);
392
+ countPeriodDays(input.stay); // 7
393
+ ```
394
+
395
+ **Local date-times.** A `LocalDateTime` is `YYYY-MM-DDTHH:mm` with no time zone: a time that is local at a
396
+ place, such as a departure or a booking slot, read the same whatever the server's zone. The day part must be a
397
+ real calendar day (`2026-02-30T10:00` is refused), the hour 00-23 and the minute 00-59; no seconds, no offset.
398
+ It is a branded string, so only `localDateTimeSchema` (input) or `isLocalDateTime(value)` (a guard) make one,
399
+ and it sorts as text in time order. `getLocalDay(value)` returns its `YYYY-MM-DD` day.
400
+
401
+ ```ts
402
+ const departure = localDateTimeSchema.parse("2026-12-01T06:45"); // LocalDateTime
403
+ isDayInPeriod(getLocalDay(departure), trip); // true when the departure falls on a day of the trip
404
+ ```
405
+
338
406
  **Display.** One way to show days, money and percentages in every module and app, in `config.locale`:
339
407
 
340
408
  ```ts
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ /** Calendar days from `from` to `to`, both included; `to` never comes before `from`. */
3
+ export interface CalendarPeriod {
4
+ readonly from: string;
5
+ readonly to: string;
6
+ }
7
+ /** A `CalendarPeriod` in tool and form input: two calendar days, `to` refused (path `["to"]`) when it comes first. */
8
+ export declare const calendarPeriodSchema: z.ZodObject<{
9
+ from: z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>;
10
+ to: z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>;
11
+ }, z.core.$strip>;
12
+ /** Whether `day` lies in `period`, both ends included. Throws a `RangeError` for a malformed day or period. */
13
+ export declare function isDayInPeriod(day: string, period: CalendarPeriod): boolean;
14
+ /** Whether `a` and `b` share at least one day; adjacent periods do not. Throws a `RangeError` for a malformed period. */
15
+ export declare function doPeriodsOverlap(a: CalendarPeriod, b: CalendarPeriod): boolean;
16
+ /** The number of days in `period`, both ends included: 1 for a single day. Throws a `RangeError` for a malformed period. */
17
+ export declare function countPeriodDays(period: CalendarPeriod): number;
18
+ //# sourceMappingURL=calendar-period.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"calendar-period.d.ts","sourceRoot":"","sources":["../src/calendar-period.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,wFAAwF;AACxF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACrB;AAED,sHAAsH;AACtH,eAAO,MAAM,oBAAoB;;;iBAO7B,CAAC;AAEL,+GAA+G;AAC/G,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CAK1E;AAED,yHAAyH;AACzH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,cAAc,EAAE,CAAC,EAAE,cAAc,GAAG,OAAO,CAK9E;AAED,4HAA4H;AAC5H,wBAAgB,eAAe,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CAG9D"}
@@ -0,0 +1,44 @@
1
+ // A calendar period: two calendar days (`YYYY-MM-DD`) with both ends included, so one day is
2
+ // `{ from: d, to: d }`. Zero-padded days sort as text in calendar order, so comparisons work on the strings.
3
+ import { z } from "zod";
4
+ import { calendarDaysBetween, calendarDaySchema, isCalendarDay } from "./calendar-day.js";
5
+ /** A `CalendarPeriod` in tool and form input: two calendar days, `to` refused (path `["to"]`) when it comes first. */
6
+ export const calendarPeriodSchema = z
7
+ .object({ from: calendarDaySchema, to: calendarDaySchema })
8
+ // A malformed end already has its own issue, and zod still runs the refinement then: skip it, so the
9
+ // order is checked only between two real days.
10
+ .refine((period) => !isCalendarDay(period.from) || !isCalendarDay(period.to) || period.from <= period.to, {
11
+ message: "must not come before from",
12
+ path: ["to"],
13
+ });
14
+ /** Whether `day` lies in `period`, both ends included. Throws a `RangeError` for a malformed day or period. */
15
+ export function isDayInPeriod(day, period) {
16
+ const operation = "isDayInPeriod";
17
+ assertCalendarDay(operation, day);
18
+ assertPeriod(operation, period);
19
+ return period.from <= day && day <= period.to;
20
+ }
21
+ /** Whether `a` and `b` share at least one day; adjacent periods do not. Throws a `RangeError` for a malformed period. */
22
+ export function doPeriodsOverlap(a, b) {
23
+ const operation = "doPeriodsOverlap";
24
+ assertPeriod(operation, a);
25
+ assertPeriod(operation, b);
26
+ return a.from <= b.to && b.from <= a.to;
27
+ }
28
+ /** The number of days in `period`, both ends included: 1 for a single day. Throws a `RangeError` for a malformed period. */
29
+ export function countPeriodDays(period) {
30
+ assertPeriod("countPeriodDays", period);
31
+ return calendarDaysBetween(period.from, period.to) + 1;
32
+ }
33
+ function assertPeriod(operation, period) {
34
+ assertCalendarDay(operation, period.from);
35
+ assertCalendarDay(operation, period.to);
36
+ if (period.to < period.from) {
37
+ throw new RangeError(`${operation}: the period ends ("${period.to}") before it starts ("${period.from}")`);
38
+ }
39
+ }
40
+ function assertCalendarDay(operation, day) {
41
+ if (!isCalendarDay(day))
42
+ throw new RangeError(`${operation}: "${String(day)}" is not a YYYY-MM-DD calendar day`);
43
+ }
44
+ //# sourceMappingURL=calendar-period.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"calendar-period.js","sourceRoot":"","sources":["../src/calendar-period.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,6GAA6G;AAC7G,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAQ1F,sHAAsH;AACtH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC;KAClC,MAAM,CAAC,EAAE,IAAI,EAAE,iBAAiB,EAAE,EAAE,EAAE,iBAAiB,EAAE,CAAC;IAC3D,qGAAqG;IACrG,+CAA+C;KAC9C,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,EAAE,EAAE;IACxG,OAAO,EAAE,2BAA2B;IACpC,IAAI,EAAE,CAAC,IAAI,CAAC;CACb,CAAC,CAAC;AAEL,+GAA+G;AAC/G,MAAM,UAAU,aAAa,CAAC,GAAW,EAAE,MAAsB;IAC/D,MAAM,SAAS,GAAG,eAAe,CAAC;IAClC,iBAAiB,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;IAClC,YAAY,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;IAChC,OAAO,MAAM,CAAC,IAAI,IAAI,GAAG,IAAI,GAAG,IAAI,MAAM,CAAC,EAAE,CAAC;AAChD,CAAC;AAED,yHAAyH;AACzH,MAAM,UAAU,gBAAgB,CAAC,CAAiB,EAAE,CAAiB;IACnE,MAAM,SAAS,GAAG,kBAAkB,CAAC;IACrC,YAAY,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;IAC3B,YAAY,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;IAC3B,OAAO,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,EAAE,CAAC;AAC1C,CAAC;AAED,4HAA4H;AAC5H,MAAM,UAAU,eAAe,CAAC,MAAsB;IACpD,YAAY,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC;IACxC,OAAO,mBAAmB,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC;AACzD,CAAC;AAED,SAAS,YAAY,CAAC,SAAiB,EAAE,MAAsB;IAC7D,iBAAiB,CAAC,SAAS,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC;IAC1C,iBAAiB,CAAC,SAAS,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;IACxC,IAAI,MAAM,CAAC,EAAE,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,IAAI,UAAU,CAAC,GAAG,SAAS,uBAAuB,MAAM,CAAC,EAAE,yBAAyB,MAAM,CAAC,IAAI,IAAI,CAAC,CAAC;IAC7G,CAAC;AACH,CAAC;AAED,SAAS,iBAAiB,CAAC,SAAiB,EAAE,GAAW;IACvD,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC;QAAE,MAAM,IAAI,UAAU,CAAC,GAAG,SAAS,MAAM,MAAM,CAAC,GAAG,CAAC,oCAAoC,CAAC,CAAC;AACnH,CAAC"}
@@ -0,0 +1,32 @@
1
+ import type { z } from "zod";
2
+ import { type Err, type Ok } from "./result.js";
3
+ export type HttpJsonFailureCode = "core.http_unreachable" | "core.http_refused" | "core.http_rate_limited" | "core.http_unavailable" | "core.http_unexpected_answer";
4
+ /** A failed `requestJson`: the code and the HTTP status (`null` when no response arrived). Nothing else. */
5
+ export type HttpJsonFailure = Err<HttpJsonFailureCode> & {
6
+ readonly status: number | null;
7
+ };
8
+ export type FetchFunction = (input: string | URL, init: RequestInit) => Promise<Response>;
9
+ export interface RequestJsonOptions {
10
+ readonly url: string | URL;
11
+ /** Defaults to the global `fetch`; tests pass a fake. */
12
+ readonly fetch?: FetchFunction;
13
+ readonly headers?: HeadersInit;
14
+ /** Milliseconds before the request (body included) gives up. Defaults to 10 000. */
15
+ readonly timeoutMs?: number;
16
+ }
17
+ export interface RequestJsonSchemaOptions<S extends z.ZodType> extends RequestJsonOptions {
18
+ readonly schema: S;
19
+ }
20
+ /**
21
+ * GET `url` and read its JSON body. Without a schema the value is `unknown`, for the caller to narrow; with one
22
+ * it is the schema's output. 3xx, 401 and 403 are refused, 429 rate limited, 5xx unavailable, and any other
23
+ * non-2xx status or a body that is not JSON or fails the schema an unexpected answer.
24
+ */
25
+ export declare function requestJson<S extends z.ZodType>(options: RequestJsonSchemaOptions<S>): Promise<Ok<z.output<S>> | HttpJsonFailure>;
26
+ export declare function requestJson(options: RequestJsonOptions): Promise<Ok<unknown> | HttpJsonFailure>;
27
+ /**
28
+ * The query part of a URL (without `?`) from a flat record, in insertion order. `null` and `undefined` are left
29
+ * out, so optional parameters can be passed as they are.
30
+ */
31
+ export declare function toQueryString(params: Readonly<Record<string, string | number | boolean | null | undefined>>): string;
32
+ //# sourceMappingURL=http-json.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-json.d.ts","sourceRoot":"","sources":["../src/http-json.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAC7B,OAAO,EAAW,KAAK,GAAG,EAAE,KAAK,EAAE,EAAE,MAAM,aAAa,CAAC;AAEzD,MAAM,MAAM,mBAAmB,GAC3B,uBAAuB,GACvB,mBAAmB,GACnB,wBAAwB,GACxB,uBAAuB,GACvB,6BAA6B,CAAC;AAElC,4GAA4G;AAC5G,MAAM,MAAM,eAAe,GAAG,GAAG,CAAC,mBAAmB,CAAC,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AAE5F,MAAM,MAAM,aAAa,GAAG,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAE1F,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;IAC3B,yDAAyD;IACzD,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,OAAO,CAAC,EAAE,WAAW,CAAC;IAC/B,oFAAoF;IACpF,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,wBAAwB,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,CAAE,SAAQ,kBAAkB;IACvF,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;CACpB;AAID;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,EAC7C,OAAO,EAAE,wBAAwB,CAAC,CAAC,CAAC,GACnC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,eAAe,CAAC,CAAC;AAC9C,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,eAAe,CAAC,CAAC;AAsDjG;;;GAGG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC,GAAG,MAAM,CAQpH"}
@@ -0,0 +1,83 @@
1
+ import { err, ok } from "./result.js";
2
+ const DEFAULT_TIMEOUT_MS = 10_000;
3
+ export async function requestJson(options) {
4
+ const fetchFunction = options.fetch ?? globalThis.fetch;
5
+ let response;
6
+ try {
7
+ // Inside the try: an invalid header value throws a message that quotes it.
8
+ const headers = new Headers(options.headers);
9
+ if (!headers.has("accept")) {
10
+ headers.set("accept", "application/json");
11
+ }
12
+ response = await fetchFunction(options.url, {
13
+ method: "GET",
14
+ headers,
15
+ redirect: "manual",
16
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
17
+ });
18
+ }
19
+ catch {
20
+ // Network error or timeout. The error's text can quote the URL, so it is dropped on purpose.
21
+ return httpFailure("core.http_unreachable", null);
22
+ }
23
+ const statusFailure = getStatusFailure(response);
24
+ if (statusFailure !== null) {
25
+ // Release the connection; a body that cannot be cancelled changes nothing for the caller.
26
+ await response.body?.cancel().catch(() => undefined);
27
+ return statusFailure;
28
+ }
29
+ let text;
30
+ try {
31
+ text = await response.text();
32
+ }
33
+ catch {
34
+ // The body broke off, usually the timeout firing mid-body.
35
+ return httpFailure("core.http_unreachable", response.status);
36
+ }
37
+ let body;
38
+ try {
39
+ body = JSON.parse(text);
40
+ }
41
+ catch {
42
+ return httpFailure("core.http_unexpected_answer", response.status);
43
+ }
44
+ if (options.schema === undefined) {
45
+ return ok(body);
46
+ }
47
+ // The schema's issues can quote the body's values, so only the code leaves.
48
+ const parsed = options.schema.safeParse(body);
49
+ return parsed.success ? ok(parsed.data) : httpFailure("core.http_unexpected_answer", response.status);
50
+ }
51
+ /**
52
+ * The query part of a URL (without `?`) from a flat record, in insertion order. `null` and `undefined` are left
53
+ * out, so optional parameters can be passed as they are.
54
+ */
55
+ export function toQueryString(params) {
56
+ const search = new URLSearchParams();
57
+ for (const [key, value] of Object.entries(params)) {
58
+ if (value !== null && value !== undefined) {
59
+ search.append(key, String(value));
60
+ }
61
+ }
62
+ return search.toString();
63
+ }
64
+ function getStatusFailure(response) {
65
+ const { status } = response;
66
+ if (response.type === "opaqueredirect" || (status >= 300 && status < 400) || status === 401 || status === 403) {
67
+ return httpFailure("core.http_refused", status);
68
+ }
69
+ if (status === 429) {
70
+ return httpFailure("core.http_rate_limited", status);
71
+ }
72
+ if (status >= 500) {
73
+ return httpFailure("core.http_unavailable", status);
74
+ }
75
+ if (status < 200 || status >= 300) {
76
+ return httpFailure("core.http_unexpected_answer", status);
77
+ }
78
+ return null;
79
+ }
80
+ function httpFailure(code, status) {
81
+ return { ...err(code), status };
82
+ }
83
+ //# sourceMappingURL=http-json.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-json.js","sourceRoot":"","sources":["../src/http-json.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export { addCalendarDays, addCalendarMonths, calendarDaysBetween, calendarDaySchema, getCalendarDay, isCalendarDay, toCalendarDay, wholeMonthsBetween, type EndOfMonth, } from "./calendar-day.js";
2
+ export { calendarPeriodSchema, countPeriodDays, doPeriodsOverlap, isDayInPeriod, type CalendarPeriod, } from "./calendar-period.js";
3
+ export { getLocalDay, isLocalDateTime, localDateTimeSchema, type LocalDateTime } from "./local-date-time.js";
2
4
  export { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
3
5
  export { DEFAULT_DURATION_MESSAGES, formatCalendarDay, formatCalendarMonth, formatDuration, formatMoney, formatPercent, isCalendarMonth, type CalendarDayStyle, type CalendarMonthStyle, type DurationMessages, type FormatDurationOptions, type FormatMoneyOptions, type FormatPercentOptions, } from "./format.js";
6
+ export { requestJson, toQueryString, type FetchFunction, type HttpJsonFailure, type HttpJsonFailureCode, type RequestJsonOptions, type RequestJsonSchemaOptions, } from "./http-json.js";
4
7
  export { memoizeByContent, type MemoizeByContentOptions, type MemoizedFunction } from "./memoize.js";
5
8
  export { createTestClock, systemClock, type Clock, type TestClock } from "./clock.js";
6
9
  export { formatMessage, getMessage, DEFAULT_LOCALE, isLocale, mergeMessages, pickLocalizedText, selectMessages, selectPlural, type DeepPartial, type Dictionaries, type Locale, type LocalizedText, type MessageOverrides, type MessageTree, type PluralForms, } from "./i18n.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,8 +1,11 @@
1
1
  // Public API of @softure-ai/core: the contract every SOFTURE module stands on
2
2
  // (docs/02-module-standard.md). The Next.js registry is a separate entry: `@softure-ai/core/next`.
3
3
  export { addCalendarDays, addCalendarMonths, calendarDaysBetween, calendarDaySchema, getCalendarDay, isCalendarDay, toCalendarDay, wholeMonthsBetween, } from "./calendar-day.js";
4
+ export { calendarPeriodSchema, countPeriodDays, doPeriodsOverlap, isDayInPeriod, } from "./calendar-period.js";
5
+ export { getLocalDay, isLocalDateTime, localDateTimeSchema } from "./local-date-time.js";
4
6
  export { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
5
7
  export { DEFAULT_DURATION_MESSAGES, formatCalendarDay, formatCalendarMonth, formatDuration, formatMoney, formatPercent, isCalendarMonth, } from "./format.js";
8
+ export { requestJson, toQueryString, } from "./http-json.js";
6
9
  export { memoizeByContent } from "./memoize.js";
7
10
  export { createTestClock, systemClock } from "./clock.js";
8
11
  export { formatMessage, getMessage, DEFAULT_LOCALE, isLocale, mergeMessages, pickLocalizedText, selectMessages, selectPlural, } from "./i18n.js";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,mGAAmG;AACnG,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,EACjB,cAAc,EACd,aAAa,EACb,aAAa,EACb,kBAAkB,GAEnB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,0BAA0B,EAAE,MAAM,sBAAsB,CAAC;AAClE,OAAO,EACL,yBAAyB,EACzB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,WAAW,EACX,aAAa,EACb,eAAe,GAOhB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,gBAAgB,EAAuD,MAAM,cAAc,CAAC;AACrG,OAAO,EAAE,eAAe,EAAE,WAAW,EAA8B,MAAM,YAAY,CAAC;AACtF,OAAO,EACL,aAAa,EACb,UAAU,EACV,cAAc,EACd,QAAQ,EACR,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,YAAY,GAQb,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,YAAY,EAAqB,MAAM,qBAAqB,CAAC;AACtE,OAAO,EAAE,GAAG,EAAE,EAAE,EAAkD,MAAM,aAAa,CAAC;AACtF,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAsB,MAAM,iBAAiB,CAAC;AAC7H,OAAO,EACL,mBAAmB,EACnB,SAAS,EACT,yBAAyB,GAQ1B,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,EAAE,oBAAoB,EAAuB,MAAM,eAAe,CAAC;AAC1E,OAAO,EACL,YAAY,EACZ,oBAAoB,EACpB,YAAY,GAUb,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,iBAAiB,EACjB,WAAW,EACX,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,gBAAgB,GAIjB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,mBAAmB,EAAE,WAAW,EAAuC,MAAM,gBAAgB,CAAC;AACvG,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAyC,MAAM,eAAe,CAAC;AACpG,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,cAAc,EAAmC,MAAM,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,mGAAmG;AACnG,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,EACjB,cAAc,EACd,aAAa,EACb,aAAa,EACb,kBAAkB,GAEnB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,oBAAoB,EACpB,eAAe,EACf,gBAAgB,EAChB,aAAa,GAEd,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,mBAAmB,EAAsB,MAAM,sBAAsB,CAAC;AAC7G,OAAO,EAAE,0BAA0B,EAAE,MAAM,sBAAsB,CAAC;AAClE,OAAO,EACL,yBAAyB,EACzB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,WAAW,EACX,aAAa,EACb,eAAe,GAOhB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,WAAW,EACX,aAAa,GAMd,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,gBAAgB,EAAuD,MAAM,cAAc,CAAC;AACrG,OAAO,EAAE,eAAe,EAAE,WAAW,EAA8B,MAAM,YAAY,CAAC;AACtF,OAAO,EACL,aAAa,EACb,UAAU,EACV,cAAc,EACd,QAAQ,EACR,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,YAAY,GAQb,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,YAAY,EAAqB,MAAM,qBAAqB,CAAC;AACtE,OAAO,EAAE,GAAG,EAAE,EAAE,EAAkD,MAAM,aAAa,CAAC;AACtF,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAsB,MAAM,iBAAiB,CAAC;AAC7H,OAAO,EACL,mBAAmB,EACnB,SAAS,EACT,yBAAyB,GAQ1B,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,EAAE,oBAAoB,EAAuB,MAAM,eAAe,CAAC;AAC1E,OAAO,EACL,YAAY,EACZ,oBAAoB,EACpB,YAAY,GAUb,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,iBAAiB,EACjB,WAAW,EACX,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,gBAAgB,GAIjB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,mBAAmB,EAAE,WAAW,EAAuC,MAAM,gBAAgB,CAAC;AACvG,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAyC,MAAM,eAAe,CAAC;AACpG,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,cAAc,EAAmC,MAAM,oBAAoB,CAAC"}
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+ /** A string checked by `isLocalDateTime` or `localDateTimeSchema`; a plain string is not one. */
3
+ export type LocalDateTime = string & z.$brand<"LocalDateTime">;
4
+ /** Whether `value` is a `YYYY-MM-DDTHH:mm` local date-time on a real day (`2026-02-30T10:00` and `…T24:00` are not). */
5
+ export declare function isLocalDateTime(value: unknown): value is LocalDateTime;
6
+ /** A `YYYY-MM-DDTHH:mm` local date-time in tool and form input, parsed into the branded `LocalDateTime`. */
7
+ export declare const localDateTimeSchema: z.core.$ZodBranded<z.ZodString & z.ZodType<LocalDateTime, string, z.core.$ZodTypeInternals<LocalDateTime, string>>, "LocalDateTime", "out">;
8
+ /** The calendar day (`YYYY-MM-DD`) of a local date-time. Throws a `RangeError` for a malformed value. */
9
+ export declare function getLocalDay(value: LocalDateTime): string;
10
+ //# sourceMappingURL=local-date-time.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-date-time.d.ts","sourceRoot":"","sources":["../src/local-date-time.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,iGAAiG;AACjG,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;AAE/D,wHAAwH;AACxH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,aAAa,CAItE;AAED,4GAA4G;AAC5G,eAAO,MAAM,mBAAmB,6IAGL,CAAC;AAE5B,yGAAyG;AACzG,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAKxD"}
@@ -0,0 +1,25 @@
1
+ // A local wall-clock date-time, `YYYY-MM-DDTHH:mm` with no time zone: a time that is local at a place
2
+ // (a departure, a booking slot), independent of the server's zone. Zero-padded, so it sorts as text.
3
+ import { z } from "zod";
4
+ import { isCalendarDay } from "./calendar-day.js";
5
+ const LOCAL_DATE_TIME_PATTERN = /^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d$/;
6
+ /** Whether `value` is a `YYYY-MM-DDTHH:mm` local date-time on a real day (`2026-02-30T10:00` and `…T24:00` are not). */
7
+ export function isLocalDateTime(value) {
8
+ if (typeof value !== "string")
9
+ return false;
10
+ const match = LOCAL_DATE_TIME_PATTERN.exec(value);
11
+ return match !== null && isCalendarDay(match[1]);
12
+ }
13
+ /** A `YYYY-MM-DDTHH:mm` local date-time in tool and form input, parsed into the branded `LocalDateTime`. */
14
+ export const localDateTimeSchema = z
15
+ .string()
16
+ .refine(isLocalDateTime, "must be a YYYY-MM-DDTHH:mm local date-time on a real day, e.g. 2026-10-04T09:30")
17
+ .brand();
18
+ /** The calendar day (`YYYY-MM-DD`) of a local date-time. Throws a `RangeError` for a malformed value. */
19
+ export function getLocalDay(value) {
20
+ if (!isLocalDateTime(value)) {
21
+ throw new RangeError(`getLocalDay: "${String(value)}" is not a YYYY-MM-DDTHH:mm local date-time`);
22
+ }
23
+ return value.slice(0, "YYYY-MM-DD".length);
24
+ }
25
+ //# sourceMappingURL=local-date-time.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-date-time.js","sourceRoot":"","sources":["../src/local-date-time.ts"],"names":[],"mappings":"AAAA,sGAAsG;AACtG,qGAAqG;AACrG,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAElD,MAAM,uBAAuB,GAAG,+CAA+C,CAAC;AAKhF,wHAAwH;AACxH,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5C,MAAM,KAAK,GAAG,uBAAuB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAClD,OAAO,KAAK,KAAK,IAAI,IAAI,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;AACnD,CAAC;AAED,4GAA4G;AAC5G,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC;KACjC,MAAM,EAAE;KACR,MAAM,CAAC,eAAe,EAAE,iFAAiF,CAAC;KAC1G,KAAK,EAAmB,CAAC;AAE5B,yGAAyG;AACzG,MAAM,UAAU,WAAW,CAAC,KAAoB;IAC9C,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC,EAAE,CAAC;QAC5B,MAAM,IAAI,UAAU,CAAC,iBAAiB,MAAM,CAAC,KAAK,CAAC,6CAA6C,CAAC,CAAC;IACpG,CAAC;IACD,OAAO,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,YAAY,CAAC,MAAM,CAAC,CAAC;AAC7C,CAAC"}
@@ -2,6 +2,11 @@ export declare const en: {
2
2
  errors: {
3
3
  database_failed: string;
4
4
  unexpected: string;
5
+ http_unreachable: string;
6
+ http_refused: string;
7
+ http_rate_limited: string;
8
+ http_unavailable: string;
9
+ http_unexpected_answer: string;
5
10
  };
6
11
  };
7
12
  //# sourceMappingURL=en.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/messages/en.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,EAAE;;;;;CAKd,CAAC"}
1
+ {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/messages/en.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,EAAE;;;;;;;;;;CAUd,CAAC"}
@@ -2,6 +2,11 @@ export const en = {
2
2
  errors: {
3
3
  database_failed: "Something went wrong while saving or loading data. Please try again.",
4
4
  unexpected: "Something went wrong. Please try again.",
5
+ http_unreachable: "The service could not be reached. Please try again later.",
6
+ http_refused: "The service refused the request.",
7
+ http_rate_limited: "The service is receiving too many requests. Please try again later.",
8
+ http_unavailable: "The service is unavailable right now. Please try again later.",
9
+ http_unexpected_answer: "The service sent an answer that could not be read.",
5
10
  },
6
11
  };
7
12
  //# sourceMappingURL=en.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"en.js","sourceRoot":"","sources":["../../src/messages/en.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,EAAE,GAAG;IAChB,MAAM,EAAE;QACN,eAAe,EAAE,sEAAsE;QACvF,UAAU,EAAE,yCAAyC;KACtD;CACF,CAAC"}
1
+ {"version":3,"file":"en.js","sourceRoot":"","sources":["../../src/messages/en.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,EAAE,GAAG;IAChB,MAAM,EAAE;QACN,eAAe,EAAE,sEAAsE;QACvF,UAAU,EAAE,yCAAyC;QACrD,gBAAgB,EAAE,2DAA2D;QAC7E,YAAY,EAAE,kCAAkC;QAChD,iBAAiB,EAAE,qEAAqE;QACxF,gBAAgB,EAAE,+DAA+D;QACjF,sBAAsB,EAAE,oDAAoD;KAC7E;CACF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softure-ai/core",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "The SOFTURE AI module contract: app config, module definitions, Result, Clock and messages.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,54 @@
1
+ // A calendar period: two calendar days (`YYYY-MM-DD`) with both ends included, so one day is
2
+ // `{ from: d, to: d }`. Zero-padded days sort as text in calendar order, so comparisons work on the strings.
3
+ import { z } from "zod";
4
+ import { calendarDaysBetween, calendarDaySchema, isCalendarDay } from "./calendar-day.js";
5
+
6
+ /** Calendar days from `from` to `to`, both included; `to` never comes before `from`. */
7
+ export interface CalendarPeriod {
8
+ readonly from: string;
9
+ readonly to: string;
10
+ }
11
+
12
+ /** A `CalendarPeriod` in tool and form input: two calendar days, `to` refused (path `["to"]`) when it comes first. */
13
+ export const calendarPeriodSchema = z
14
+ .object({ from: calendarDaySchema, to: calendarDaySchema })
15
+ // A malformed end already has its own issue, and zod still runs the refinement then: skip it, so the
16
+ // order is checked only between two real days.
17
+ .refine((period) => !isCalendarDay(period.from) || !isCalendarDay(period.to) || period.from <= period.to, {
18
+ message: "must not come before from",
19
+ path: ["to"],
20
+ });
21
+
22
+ /** Whether `day` lies in `period`, both ends included. Throws a `RangeError` for a malformed day or period. */
23
+ export function isDayInPeriod(day: string, period: CalendarPeriod): boolean {
24
+ const operation = "isDayInPeriod";
25
+ assertCalendarDay(operation, day);
26
+ assertPeriod(operation, period);
27
+ return period.from <= day && day <= period.to;
28
+ }
29
+
30
+ /** Whether `a` and `b` share at least one day; adjacent periods do not. Throws a `RangeError` for a malformed period. */
31
+ export function doPeriodsOverlap(a: CalendarPeriod, b: CalendarPeriod): boolean {
32
+ const operation = "doPeriodsOverlap";
33
+ assertPeriod(operation, a);
34
+ assertPeriod(operation, b);
35
+ return a.from <= b.to && b.from <= a.to;
36
+ }
37
+
38
+ /** The number of days in `period`, both ends included: 1 for a single day. Throws a `RangeError` for a malformed period. */
39
+ export function countPeriodDays(period: CalendarPeriod): number {
40
+ assertPeriod("countPeriodDays", period);
41
+ return calendarDaysBetween(period.from, period.to) + 1;
42
+ }
43
+
44
+ function assertPeriod(operation: string, period: CalendarPeriod): void {
45
+ assertCalendarDay(operation, period.from);
46
+ assertCalendarDay(operation, period.to);
47
+ if (period.to < period.from) {
48
+ throw new RangeError(`${operation}: the period ends ("${period.to}") before it starts ("${period.from}")`);
49
+ }
50
+ }
51
+
52
+ function assertCalendarDay(operation: string, day: string): void {
53
+ if (!isCalendarDay(day)) throw new RangeError(`${operation}: "${String(day)}" is not a YYYY-MM-DD calendar day`);
54
+ }
@@ -0,0 +1,130 @@
1
+ // A JSON GET to a third-party API, with the details every app's own copy has to get right: a timeout, no redirect
2
+ // followed (a key in a header or the URL never reaches another host), and every failure returned as a code. A caught
3
+ // error's message is never read, since it can quote the URL and some APIs put the key there. The body stays
4
+ // `unknown` unless a zod schema narrows it.
5
+ import type { z } from "zod";
6
+ import { err, ok, type Err, type Ok } from "./result.js";
7
+
8
+ export type HttpJsonFailureCode =
9
+ | "core.http_unreachable"
10
+ | "core.http_refused"
11
+ | "core.http_rate_limited"
12
+ | "core.http_unavailable"
13
+ | "core.http_unexpected_answer";
14
+
15
+ /** A failed `requestJson`: the code and the HTTP status (`null` when no response arrived). Nothing else. */
16
+ export type HttpJsonFailure = Err<HttpJsonFailureCode> & { readonly status: number | null };
17
+
18
+ export type FetchFunction = (input: string | URL, init: RequestInit) => Promise<Response>;
19
+
20
+ export interface RequestJsonOptions {
21
+ readonly url: string | URL;
22
+ /** Defaults to the global `fetch`; tests pass a fake. */
23
+ readonly fetch?: FetchFunction;
24
+ readonly headers?: HeadersInit;
25
+ /** Milliseconds before the request (body included) gives up. Defaults to 10 000. */
26
+ readonly timeoutMs?: number;
27
+ }
28
+
29
+ export interface RequestJsonSchemaOptions<S extends z.ZodType> extends RequestJsonOptions {
30
+ readonly schema: S;
31
+ }
32
+
33
+ const DEFAULT_TIMEOUT_MS = 10_000;
34
+
35
+ /**
36
+ * GET `url` and read its JSON body. Without a schema the value is `unknown`, for the caller to narrow; with one
37
+ * it is the schema's output. 3xx, 401 and 403 are refused, 429 rate limited, 5xx unavailable, and any other
38
+ * non-2xx status or a body that is not JSON or fails the schema an unexpected answer.
39
+ */
40
+ export function requestJson<S extends z.ZodType>(
41
+ options: RequestJsonSchemaOptions<S>,
42
+ ): Promise<Ok<z.output<S>> | HttpJsonFailure>;
43
+ export function requestJson(options: RequestJsonOptions): Promise<Ok<unknown> | HttpJsonFailure>;
44
+ export async function requestJson(
45
+ options: RequestJsonOptions & { readonly schema?: z.ZodType },
46
+ ): Promise<Ok<unknown> | HttpJsonFailure> {
47
+ const fetchFunction = options.fetch ?? globalThis.fetch;
48
+
49
+ let response: Response;
50
+ try {
51
+ // Inside the try: an invalid header value throws a message that quotes it.
52
+ const headers = new Headers(options.headers);
53
+ if (!headers.has("accept")) {
54
+ headers.set("accept", "application/json");
55
+ }
56
+ response = await fetchFunction(options.url, {
57
+ method: "GET",
58
+ headers,
59
+ redirect: "manual",
60
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
61
+ });
62
+ } catch {
63
+ // Network error or timeout. The error's text can quote the URL, so it is dropped on purpose.
64
+ return httpFailure("core.http_unreachable", null);
65
+ }
66
+
67
+ const statusFailure = getStatusFailure(response);
68
+ if (statusFailure !== null) {
69
+ // Release the connection; a body that cannot be cancelled changes nothing for the caller.
70
+ await response.body?.cancel().catch(() => undefined);
71
+ return statusFailure;
72
+ }
73
+
74
+ let text: string;
75
+ try {
76
+ text = await response.text();
77
+ } catch {
78
+ // The body broke off, usually the timeout firing mid-body.
79
+ return httpFailure("core.http_unreachable", response.status);
80
+ }
81
+
82
+ let body: unknown;
83
+ try {
84
+ body = JSON.parse(text);
85
+ } catch {
86
+ return httpFailure("core.http_unexpected_answer", response.status);
87
+ }
88
+
89
+ if (options.schema === undefined) {
90
+ return ok(body);
91
+ }
92
+ // The schema's issues can quote the body's values, so only the code leaves.
93
+ const parsed = options.schema.safeParse(body);
94
+ return parsed.success ? ok(parsed.data) : httpFailure("core.http_unexpected_answer", response.status);
95
+ }
96
+
97
+ /**
98
+ * The query part of a URL (without `?`) from a flat record, in insertion order. `null` and `undefined` are left
99
+ * out, so optional parameters can be passed as they are.
100
+ */
101
+ export function toQueryString(params: Readonly<Record<string, string | number | boolean | null | undefined>>): string {
102
+ const search = new URLSearchParams();
103
+ for (const [key, value] of Object.entries(params)) {
104
+ if (value !== null && value !== undefined) {
105
+ search.append(key, String(value));
106
+ }
107
+ }
108
+ return search.toString();
109
+ }
110
+
111
+ function getStatusFailure(response: Response): HttpJsonFailure | null {
112
+ const { status } = response;
113
+ if (response.type === "opaqueredirect" || (status >= 300 && status < 400) || status === 401 || status === 403) {
114
+ return httpFailure("core.http_refused", status);
115
+ }
116
+ if (status === 429) {
117
+ return httpFailure("core.http_rate_limited", status);
118
+ }
119
+ if (status >= 500) {
120
+ return httpFailure("core.http_unavailable", status);
121
+ }
122
+ if (status < 200 || status >= 300) {
123
+ return httpFailure("core.http_unexpected_answer", status);
124
+ }
125
+ return null;
126
+ }
127
+
128
+ function httpFailure(code: HttpJsonFailureCode, status: number | null): HttpJsonFailure {
129
+ return { ...err(code), status };
130
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,14 @@ export {
11
11
  wholeMonthsBetween,
12
12
  type EndOfMonth,
13
13
  } from "./calendar-day.js";
14
+ export {
15
+ calendarPeriodSchema,
16
+ countPeriodDays,
17
+ doPeriodsOverlap,
18
+ isDayInPeriod,
19
+ type CalendarPeriod,
20
+ } from "./calendar-period.js";
21
+ export { getLocalDay, isLocalDateTime, localDateTimeSchema, type LocalDateTime } from "./local-date-time.js";
14
22
  export { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
15
23
  export {
16
24
  DEFAULT_DURATION_MESSAGES,
@@ -27,6 +35,15 @@ export {
27
35
  type FormatMoneyOptions,
28
36
  type FormatPercentOptions,
29
37
  } from "./format.js";
38
+ export {
39
+ requestJson,
40
+ toQueryString,
41
+ type FetchFunction,
42
+ type HttpJsonFailure,
43
+ type HttpJsonFailureCode,
44
+ type RequestJsonOptions,
45
+ type RequestJsonSchemaOptions,
46
+ } from "./http-json.js";
30
47
  export { memoizeByContent, type MemoizeByContentOptions, type MemoizedFunction } from "./memoize.js";
31
48
  export { createTestClock, systemClock, type Clock, type TestClock } from "./clock.js";
32
49
  export {
@@ -0,0 +1,30 @@
1
+ // A local wall-clock date-time, `YYYY-MM-DDTHH:mm` with no time zone: a time that is local at a place
2
+ // (a departure, a booking slot), independent of the server's zone. Zero-padded, so it sorts as text.
3
+ import { z } from "zod";
4
+ import { isCalendarDay } from "./calendar-day.js";
5
+
6
+ const LOCAL_DATE_TIME_PATTERN = /^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d$/;
7
+
8
+ /** A string checked by `isLocalDateTime` or `localDateTimeSchema`; a plain string is not one. */
9
+ export type LocalDateTime = string & z.$brand<"LocalDateTime">;
10
+
11
+ /** Whether `value` is a `YYYY-MM-DDTHH:mm` local date-time on a real day (`2026-02-30T10:00` and `…T24:00` are not). */
12
+ export function isLocalDateTime(value: unknown): value is LocalDateTime {
13
+ if (typeof value !== "string") return false;
14
+ const match = LOCAL_DATE_TIME_PATTERN.exec(value);
15
+ return match !== null && isCalendarDay(match[1]);
16
+ }
17
+
18
+ /** A `YYYY-MM-DDTHH:mm` local date-time in tool and form input, parsed into the branded `LocalDateTime`. */
19
+ export const localDateTimeSchema = z
20
+ .string()
21
+ .refine(isLocalDateTime, "must be a YYYY-MM-DDTHH:mm local date-time on a real day, e.g. 2026-10-04T09:30")
22
+ .brand<"LocalDateTime">();
23
+
24
+ /** The calendar day (`YYYY-MM-DD`) of a local date-time. Throws a `RangeError` for a malformed value. */
25
+ export function getLocalDay(value: LocalDateTime): string {
26
+ if (!isLocalDateTime(value)) {
27
+ throw new RangeError(`getLocalDay: "${String(value)}" is not a YYYY-MM-DDTHH:mm local date-time`);
28
+ }
29
+ return value.slice(0, "YYYY-MM-DD".length);
30
+ }
@@ -2,5 +2,10 @@ export const en = {
2
2
  errors: {
3
3
  database_failed: "Something went wrong while saving or loading data. Please try again.",
4
4
  unexpected: "Something went wrong. Please try again.",
5
+ http_unreachable: "The service could not be reached. Please try again later.",
6
+ http_refused: "The service refused the request.",
7
+ http_rate_limited: "The service is receiving too many requests. Please try again later.",
8
+ http_unavailable: "The service is unavailable right now. Please try again later.",
9
+ http_unexpected_answer: "The service sent an answer that could not be read.",
5
10
  },
6
11
  };