@softure-ai/core 0.2.0 → 0.2.2

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,45 @@ 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.2
8
+
9
+ - `requestJson` writes (#635): `method` (`"GET"` by default, `"POST" | "PUT" | "PATCH" | "DELETE"`), `body` sent as
10
+ JSON with `Content-Type: application/json` unless the caller sets its own, and `idempotencyKey` sent as
11
+ `Idempotency-Key`. A write answered with an empty body (204, or 200 with nothing) returns `ok(undefined)`, so a
12
+ write with a schema returns `Ok<T | undefined>`; a non-empty answer still goes through the schema. A GET takes no
13
+ body, and its empty answer stays `core.http_unexpected_answer`. New types `HttpJsonMethod`, `HttpJsonWriteMethod`,
14
+ `RequestJsonReadOptions`, `RequestJsonWriteOptions`.
15
+ - New code `core.http_conflict` for 409 and 412 (a stale version or sha), for every method, with English copy in
16
+ `coreMessages` (`errors.http_conflict`).
17
+ - Migrate: a caller that switched on `core.http_unexpected_answer` to spot a 409 or 412 switches on
18
+ `core.http_conflict` instead; an exhaustive `switch` over `HttpJsonFailureCode` gets the new case. An app with
19
+ its own write helper replaces it with `requestJson({ method, body })` and handles `ok(undefined)` for 204s.
20
+
21
+ ## 0.2.1
22
+
23
+ - New (#599): calendar periods and local wall-clock date-times next to calendar days.
24
+ - `CalendarPeriod` (`{ from, to }`, both ends included) and `calendarPeriodSchema`, which refuses `to` before
25
+ `from` on the path `["to"]` with `must not come before from`.
26
+ - `isDayInPeriod(day, period)`, `doPeriodsOverlap(a, b)` (one shared day overlaps, adjacent periods do not),
27
+ `countPeriodDays(period)` (1 for a single day).
28
+ - `LocalDateTime`, a branded `YYYY-MM-DDTHH:mm` string with no zone, made by `localDateTimeSchema` or
29
+ `isLocalDateTime(value)`; its day part goes through `isCalendarDay`, so `2026-02-30T10:00` is refused.
30
+ `getLocalDay(value)` returns the day.
31
+ - Migrate: an app that defined its own period or local date-time drops its schema, type and helpers and imports
32
+ these. Check two differences: overlap counts a single shared day, and a period's error sits on `to`. An app
33
+ that typed a local date-time as `string` passes values through `localDateTimeSchema` (or `isLocalDateTime`)
34
+ before using them as `LocalDateTime`.
35
+ - `requestJson({ url, fetch?, headers?, timeoutMs?, schema? })`: a JSON GET for third-party APIs (#598). A timeout
36
+ (default 10 000 ms), no redirect followed, `Accept: application/json` by default, and every failure returned as an
37
+ `HttpJsonFailure` with a code and the status, never the URL or the error text: `core.http_unreachable`,
38
+ `core.http_refused` (3xx, 401, 403), `core.http_rate_limited` (429), `core.http_unavailable` (5xx),
39
+ `core.http_unexpected_answer` (other status, not JSON, fails the schema). The body is `unknown` without a zod
40
+ `schema` and the schema's output with one. `toQueryString(record)` builds a query string, skipping `null` and
41
+ `undefined`. English copy for the five codes in `coreMessages` (`errors.http_*`).
42
+ - Migrate (optional): an app with its own request helper replaces it with `requestJson`, passes the same headers
43
+ and timeout, moves its response check into `schema`, and maps the five codes to its own codes and messages
44
+ (`result.error`, `result.status`). The helper's own timeout, redirect and error-scrubbing code can go.
45
+
7
46
  ## 0.2.0
8
47
 
9
48
  - **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,65 @@ 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, method?, body?, idempotencyKey?, fetch?, headers?, timeoutMs?, schema? })`
309
+ calls a third-party JSON API (a GET by default) 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_conflict` | 409, 412 (a stale version or sha: re-read, then retry) |
323
+ | `core.http_rate_limited` | 429 |
324
+ | `core.http_unavailable` | 5xx |
325
+ | `core.http_unexpected_answer` | any other non-2xx status, a body that is not JSON or fails the schema |
326
+
327
+ Without `schema` the value is `unknown`, to be narrowed at the boundary; with a zod schema it is the schema's
328
+ output. `fetch` defaults to the global one; tests pass a fake. `toQueryString(record)` builds the query part
329
+ (without `?`) from strings, numbers and booleans, leaving out `null` and `undefined`:
330
+
331
+ ```ts
332
+ import { err, requestJson, toQueryString } from "@softure-ai/core";
333
+ import { z } from "zod";
334
+
335
+ const rateSchema = z.object({ rates: z.array(z.object({ mid: z.number() })) });
336
+
337
+ const result = await requestJson({
338
+ url: `https://api.example.com/rates?${toQueryString({ currency, date, page: undefined })}`,
339
+ headers: { Authorization: `Bearer ${env.RATES_API_KEY}` },
340
+ timeoutMs: 5_000,
341
+ schema: rateSchema,
342
+ });
343
+ if (!result.ok) {
344
+ return err(result.error === "core.http_rate_limited" ? "rates.busy" : "rates.unavailable"); // the app's own codes
345
+ }
346
+ ```
347
+
348
+ Writes take `method: "POST" | "PUT" | "PATCH" | "DELETE"` and a `body`, sent as JSON with
349
+ `Content-Type: application/json` unless `headers` sets its own; a value JSON cannot represent (a cycle, a BigInt)
350
+ throws before anything is sent. `idempotencyKey` is sent as `Idempotency-Key`. A write answered with an empty body
351
+ (a 204, or a 200 with nothing) is `ok(undefined)`, schema or not, so a write's value is `T | undefined`; a non-empty
352
+ answer still goes through the schema. A GET carries no body, and an empty answer to it stays an unexpected answer.
353
+
354
+ ```ts
355
+ const saved = await requestJson({
356
+ url: `https://api.example.com/tasks/${task.id}`,
357
+ method: "PUT",
358
+ body: { title: task.title, version: task.version },
359
+ headers: { Authorization: `Bearer ${env.TASKS_API_KEY}` },
360
+ schema: z.object({ version: z.number() }),
361
+ });
362
+ if (!saved.ok && saved.error === "core.http_conflict") {
363
+ // someone else changed the task: read it again and retry the write
364
+ }
365
+ ```
366
+
308
367
  **Time.** Server code receives a `Clock` (`ModuleContext.clock`) instead of calling `new Date()`.
309
368
  `systemClock` is the real one; `createTestClock(start)` has `advance(ms)` and `set(date)`.
310
369
  A whole test run can also move to another day without code changes: `TEST_TODAY=YYYY-MM-DD` with the
@@ -335,6 +394,35 @@ or a fractional count.
335
394
 
336
395
  `calendarDaySchema` is `isCalendarDay` as a zod schema, for tool and form inputs that take a day.
337
396
 
397
+ **Calendar periods.** A `CalendarPeriod` is `{ from, to }`, two calendar days with **both ends included**:
398
+ one day is `{ from: d, to: d }`. `calendarPeriodSchema` parses one from input and refuses `to` before `from`
399
+ with the message `must not come before from` on the path `["to"]`, so a form shows it next to the end field;
400
+ a malformed day gets only its own `calendarDaySchema` issue.
401
+
402
+ | Function | Semantics |
403
+ | --- | --- |
404
+ | `isDayInPeriod(day, period)` | `day` lies in `period`, either end included. |
405
+ | `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. |
406
+ | `countPeriodDays(period)` | Days in the period, both ends counted: 1 for a single day, 31 for December. |
407
+
408
+ They throw a `RangeError` for a malformed day or a period that ends before it starts.
409
+
410
+ ```ts
411
+ const input = z.object({ stay: calendarPeriodSchema }).parse(body);
412
+ countPeriodDays(input.stay); // 7
413
+ ```
414
+
415
+ **Local date-times.** A `LocalDateTime` is `YYYY-MM-DDTHH:mm` with no time zone: a time that is local at a
416
+ place, such as a departure or a booking slot, read the same whatever the server's zone. The day part must be a
417
+ real calendar day (`2026-02-30T10:00` is refused), the hour 00-23 and the minute 00-59; no seconds, no offset.
418
+ It is a branded string, so only `localDateTimeSchema` (input) or `isLocalDateTime(value)` (a guard) make one,
419
+ and it sorts as text in time order. `getLocalDay(value)` returns its `YYYY-MM-DD` day.
420
+
421
+ ```ts
422
+ const departure = localDateTimeSchema.parse("2026-12-01T06:45"); // LocalDateTime
423
+ isDayInPeriod(getLocalDay(departure), trip); // true when the departure falls on a day of the trip
424
+ ```
425
+
338
426
  **Display.** One way to show days, money and percentages in every module and app, in `config.locale`:
339
427
 
340
428
  ```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,50 @@
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_conflict" | "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 HttpJsonWriteMethod = "POST" | "PUT" | "PATCH" | "DELETE";
9
+ export type HttpJsonMethod = "GET" | HttpJsonWriteMethod;
10
+ export type FetchFunction = (input: string | URL, init: RequestInit) => Promise<Response>;
11
+ export interface RequestJsonOptions {
12
+ readonly url: string | URL;
13
+ /** Defaults to the global `fetch`; tests pass a fake. */
14
+ readonly fetch?: FetchFunction;
15
+ readonly headers?: HeadersInit;
16
+ /** Milliseconds before the request (body included) gives up. Defaults to 10 000. */
17
+ readonly timeoutMs?: number;
18
+ }
19
+ export interface RequestJsonSchemaOptions<S extends z.ZodType> extends RequestJsonOptions {
20
+ readonly schema: S;
21
+ }
22
+ /** A GET: no body, no idempotency key (a GET is idempotent already). */
23
+ export interface RequestJsonReadOptions extends RequestJsonOptions {
24
+ readonly method?: "GET";
25
+ readonly body?: never;
26
+ readonly idempotencyKey?: never;
27
+ }
28
+ /** A write: the body is sent as JSON, and an answer with no body is `ok(undefined)`. */
29
+ export interface RequestJsonWriteOptions extends RequestJsonOptions {
30
+ readonly method: HttpJsonWriteMethod;
31
+ /** Serialised with `JSON.stringify`; `undefined` sends no body. A value JSON cannot represent throws. */
32
+ readonly body?: unknown;
33
+ /** Sent as `Idempotency-Key`, for providers that deduplicate retried writes by it. */
34
+ readonly idempotencyKey?: string;
35
+ }
36
+ /**
37
+ * Call `url` (a GET unless `method` says otherwise) and read its JSON body. Without a schema the value is `unknown`,
38
+ * for the caller to narrow; with one it is the schema's output. A write answered with an empty body (a 204) is
39
+ * `ok(undefined)`, schema or not. 3xx, 401 and 403 are refused, 409 and 412 a conflict, 429 rate limited, 5xx
40
+ * unavailable, and any other non-2xx status or a body that is not JSON or fails the schema an unexpected answer.
41
+ */
42
+ export declare function requestJson<S extends z.ZodType>(options: RequestJsonSchemaOptions<S> & RequestJsonReadOptions): Promise<Ok<z.output<S>> | HttpJsonFailure>;
43
+ export declare function requestJson<S extends z.ZodType>(options: RequestJsonSchemaOptions<S> & RequestJsonWriteOptions): Promise<Ok<z.output<S> | undefined> | HttpJsonFailure>;
44
+ export declare function requestJson(options: RequestJsonReadOptions | RequestJsonWriteOptions): Promise<Ok<unknown> | HttpJsonFailure>;
45
+ /**
46
+ * The query part of a URL (without `?`) from a flat record, in insertion order. `null` and `undefined` are left
47
+ * out, so optional parameters can be passed as they are.
48
+ */
49
+ export declare function toQueryString(params: Readonly<Record<string, string | number | boolean | null | undefined>>): string;
50
+ //# 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,oBAAoB,GACpB,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,mBAAmB,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;AACtE,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,mBAAmB,CAAC;AAEzD,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;AAED,wEAAwE;AACxE,MAAM,WAAW,sBAAuB,SAAQ,kBAAkB;IAChE,QAAQ,CAAC,MAAM,CAAC,EAAE,KAAK,CAAC;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,cAAc,CAAC,EAAE,KAAK,CAAC;CACjC;AAED,wFAAwF;AACxF,MAAM,WAAW,uBAAwB,SAAQ,kBAAkB;IACjE,QAAQ,CAAC,MAAM,EAAE,mBAAmB,CAAC;IACrC,yGAAyG;IACzG,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,sFAAsF;IACtF,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;CAClC;AAID;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,EAC7C,OAAO,EAAE,wBAAwB,CAAC,CAAC,CAAC,GAAG,sBAAsB,GAC5D,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,eAAe,CAAC,CAAC;AAC9C,wBAAgB,WAAW,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,EAC7C,OAAO,EAAE,wBAAwB,CAAC,CAAC,CAAC,GAAG,uBAAuB,GAC7D,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC,GAAG,eAAe,CAAC,CAAC;AAC1D,wBAAgB,WAAW,CACzB,OAAO,EAAE,sBAAsB,GAAG,uBAAuB,GACxD,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,eAAe,CAAC,CAAC;AAsE1C;;;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,101 @@
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
+ const method = options.method ?? "GET";
6
+ const isWrite = method !== "GET";
7
+ // Outside the try on purpose: a body JSON cannot represent is the caller's bug, not an unreachable service.
8
+ const body = options.body === undefined ? undefined : JSON.stringify(options.body);
9
+ let response;
10
+ try {
11
+ // Inside the try: an invalid header value throws a message that quotes it.
12
+ const headers = new Headers(options.headers);
13
+ if (!headers.has("accept")) {
14
+ headers.set("accept", "application/json");
15
+ }
16
+ if (body !== undefined && !headers.has("content-type")) {
17
+ headers.set("content-type", "application/json");
18
+ }
19
+ if (options.idempotencyKey !== undefined) {
20
+ headers.set("idempotency-key", options.idempotencyKey);
21
+ }
22
+ response = await fetchFunction(options.url, {
23
+ method,
24
+ headers,
25
+ body,
26
+ redirect: "manual",
27
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
28
+ });
29
+ }
30
+ catch {
31
+ // Network error or timeout. The error's text can quote the URL, so it is dropped on purpose.
32
+ return httpFailure("core.http_unreachable", null);
33
+ }
34
+ const statusFailure = getStatusFailure(response);
35
+ if (statusFailure !== null) {
36
+ // Release the connection; a body that cannot be cancelled changes nothing for the caller.
37
+ await response.body?.cancel().catch(() => undefined);
38
+ return statusFailure;
39
+ }
40
+ let text;
41
+ try {
42
+ text = await response.text();
43
+ }
44
+ catch {
45
+ // The body broke off, usually the timeout firing mid-body.
46
+ return httpFailure("core.http_unreachable", response.status);
47
+ }
48
+ if (isWrite && text.trim() === "") {
49
+ // A 204, or a write that answers 200 with nothing: the write succeeded and there is nothing to read.
50
+ return ok(undefined);
51
+ }
52
+ let answer;
53
+ try {
54
+ answer = JSON.parse(text);
55
+ }
56
+ catch {
57
+ return httpFailure("core.http_unexpected_answer", response.status);
58
+ }
59
+ if (options.schema === undefined) {
60
+ return ok(answer);
61
+ }
62
+ // The schema's issues can quote the body's values, so only the code leaves.
63
+ const parsed = options.schema.safeParse(answer);
64
+ return parsed.success ? ok(parsed.data) : httpFailure("core.http_unexpected_answer", response.status);
65
+ }
66
+ /**
67
+ * The query part of a URL (without `?`) from a flat record, in insertion order. `null` and `undefined` are left
68
+ * out, so optional parameters can be passed as they are.
69
+ */
70
+ export function toQueryString(params) {
71
+ const search = new URLSearchParams();
72
+ for (const [key, value] of Object.entries(params)) {
73
+ if (value !== null && value !== undefined) {
74
+ search.append(key, String(value));
75
+ }
76
+ }
77
+ return search.toString();
78
+ }
79
+ function getStatusFailure(response) {
80
+ const { status } = response;
81
+ if (response.type === "opaqueredirect" || (status >= 300 && status < 400) || status === 401 || status === 403) {
82
+ return httpFailure("core.http_refused", status);
83
+ }
84
+ if (status === 409 || status === 412) {
85
+ return httpFailure("core.http_conflict", status);
86
+ }
87
+ if (status === 429) {
88
+ return httpFailure("core.http_rate_limited", status);
89
+ }
90
+ if (status >= 500) {
91
+ return httpFailure("core.http_unavailable", status);
92
+ }
93
+ if (status < 200 || status >= 300) {
94
+ return httpFailure("core.http_unexpected_answer", status);
95
+ }
96
+ return null;
97
+ }
98
+ function httpFailure(code, status) {
99
+ return { ...err(code), status };
100
+ }
101
+ //# 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 HttpJsonMethod, type HttpJsonWriteMethod, type RequestJsonOptions, type RequestJsonReadOptions, type RequestJsonSchemaOptions, type RequestJsonWriteOptions, } 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,GAUd,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,12 @@ 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_conflict: string;
10
+ http_unexpected_answer: string;
5
11
  };
6
12
  };
7
13
  //# 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;;;;;;;;;;;CAWd,CAAC"}
@@ -2,6 +2,12 @@ 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_conflict: "The data changed in the meantime. Please reload it and try again.",
10
+ http_unexpected_answer: "The service sent an answer that could not be read.",
5
11
  },
6
12
  };
7
13
  //# 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,aAAa,EAAE,mEAAmE;QAClF,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.2",
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,175 @@
1
+ // A JSON call to a third-party API (a GET, or a write with a JSON body), with the details every app's own copy has to
2
+ // get right: a timeout, no redirect followed (a key in a header or the URL never reaches another host), and every
3
+ // failure returned as a code. A caught error's message is never read, since it can quote the URL and some APIs put
4
+ // the key there. The body stays `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_conflict"
14
+ | "core.http_unexpected_answer";
15
+
16
+ /** A failed `requestJson`: the code and the HTTP status (`null` when no response arrived). Nothing else. */
17
+ export type HttpJsonFailure = Err<HttpJsonFailureCode> & { readonly status: number | null };
18
+
19
+ export type HttpJsonWriteMethod = "POST" | "PUT" | "PATCH" | "DELETE";
20
+ export type HttpJsonMethod = "GET" | HttpJsonWriteMethod;
21
+
22
+ export type FetchFunction = (input: string | URL, init: RequestInit) => Promise<Response>;
23
+
24
+ export interface RequestJsonOptions {
25
+ readonly url: string | URL;
26
+ /** Defaults to the global `fetch`; tests pass a fake. */
27
+ readonly fetch?: FetchFunction;
28
+ readonly headers?: HeadersInit;
29
+ /** Milliseconds before the request (body included) gives up. Defaults to 10 000. */
30
+ readonly timeoutMs?: number;
31
+ }
32
+
33
+ export interface RequestJsonSchemaOptions<S extends z.ZodType> extends RequestJsonOptions {
34
+ readonly schema: S;
35
+ }
36
+
37
+ /** A GET: no body, no idempotency key (a GET is idempotent already). */
38
+ export interface RequestJsonReadOptions extends RequestJsonOptions {
39
+ readonly method?: "GET";
40
+ readonly body?: never;
41
+ readonly idempotencyKey?: never;
42
+ }
43
+
44
+ /** A write: the body is sent as JSON, and an answer with no body is `ok(undefined)`. */
45
+ export interface RequestJsonWriteOptions extends RequestJsonOptions {
46
+ readonly method: HttpJsonWriteMethod;
47
+ /** Serialised with `JSON.stringify`; `undefined` sends no body. A value JSON cannot represent throws. */
48
+ readonly body?: unknown;
49
+ /** Sent as `Idempotency-Key`, for providers that deduplicate retried writes by it. */
50
+ readonly idempotencyKey?: string;
51
+ }
52
+
53
+ const DEFAULT_TIMEOUT_MS = 10_000;
54
+
55
+ /**
56
+ * Call `url` (a GET unless `method` says otherwise) and read its JSON body. Without a schema the value is `unknown`,
57
+ * for the caller to narrow; with one it is the schema's output. A write answered with an empty body (a 204) is
58
+ * `ok(undefined)`, schema or not. 3xx, 401 and 403 are refused, 409 and 412 a conflict, 429 rate limited, 5xx
59
+ * unavailable, and any other non-2xx status or a body that is not JSON or fails the schema an unexpected answer.
60
+ */
61
+ export function requestJson<S extends z.ZodType>(
62
+ options: RequestJsonSchemaOptions<S> & RequestJsonReadOptions,
63
+ ): Promise<Ok<z.output<S>> | HttpJsonFailure>;
64
+ export function requestJson<S extends z.ZodType>(
65
+ options: RequestJsonSchemaOptions<S> & RequestJsonWriteOptions,
66
+ ): Promise<Ok<z.output<S> | undefined> | HttpJsonFailure>;
67
+ export function requestJson(
68
+ options: RequestJsonReadOptions | RequestJsonWriteOptions,
69
+ ): Promise<Ok<unknown> | HttpJsonFailure>;
70
+ export async function requestJson(
71
+ options: (RequestJsonReadOptions | RequestJsonWriteOptions) & { readonly schema?: z.ZodType },
72
+ ): Promise<Ok<unknown> | HttpJsonFailure> {
73
+ const fetchFunction = options.fetch ?? globalThis.fetch;
74
+ const method = options.method ?? "GET";
75
+ const isWrite = method !== "GET";
76
+ // Outside the try on purpose: a body JSON cannot represent is the caller's bug, not an unreachable service.
77
+ const body = options.body === undefined ? undefined : JSON.stringify(options.body);
78
+
79
+ let response: Response;
80
+ try {
81
+ // Inside the try: an invalid header value throws a message that quotes it.
82
+ const headers = new Headers(options.headers);
83
+ if (!headers.has("accept")) {
84
+ headers.set("accept", "application/json");
85
+ }
86
+ if (body !== undefined && !headers.has("content-type")) {
87
+ headers.set("content-type", "application/json");
88
+ }
89
+ if (options.idempotencyKey !== undefined) {
90
+ headers.set("idempotency-key", options.idempotencyKey);
91
+ }
92
+ response = await fetchFunction(options.url, {
93
+ method,
94
+ headers,
95
+ body,
96
+ redirect: "manual",
97
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
98
+ });
99
+ } catch {
100
+ // Network error or timeout. The error's text can quote the URL, so it is dropped on purpose.
101
+ return httpFailure("core.http_unreachable", null);
102
+ }
103
+
104
+ const statusFailure = getStatusFailure(response);
105
+ if (statusFailure !== null) {
106
+ // Release the connection; a body that cannot be cancelled changes nothing for the caller.
107
+ await response.body?.cancel().catch(() => undefined);
108
+ return statusFailure;
109
+ }
110
+
111
+ let text: string;
112
+ try {
113
+ text = await response.text();
114
+ } catch {
115
+ // The body broke off, usually the timeout firing mid-body.
116
+ return httpFailure("core.http_unreachable", response.status);
117
+ }
118
+
119
+ if (isWrite && text.trim() === "") {
120
+ // A 204, or a write that answers 200 with nothing: the write succeeded and there is nothing to read.
121
+ return ok(undefined);
122
+ }
123
+
124
+ let answer: unknown;
125
+ try {
126
+ answer = JSON.parse(text);
127
+ } catch {
128
+ return httpFailure("core.http_unexpected_answer", response.status);
129
+ }
130
+
131
+ if (options.schema === undefined) {
132
+ return ok(answer);
133
+ }
134
+ // The schema's issues can quote the body's values, so only the code leaves.
135
+ const parsed = options.schema.safeParse(answer);
136
+ return parsed.success ? ok(parsed.data) : httpFailure("core.http_unexpected_answer", response.status);
137
+ }
138
+
139
+ /**
140
+ * The query part of a URL (without `?`) from a flat record, in insertion order. `null` and `undefined` are left
141
+ * out, so optional parameters can be passed as they are.
142
+ */
143
+ export function toQueryString(params: Readonly<Record<string, string | number | boolean | null | undefined>>): string {
144
+ const search = new URLSearchParams();
145
+ for (const [key, value] of Object.entries(params)) {
146
+ if (value !== null && value !== undefined) {
147
+ search.append(key, String(value));
148
+ }
149
+ }
150
+ return search.toString();
151
+ }
152
+
153
+ function getStatusFailure(response: Response): HttpJsonFailure | null {
154
+ const { status } = response;
155
+ if (response.type === "opaqueredirect" || (status >= 300 && status < 400) || status === 401 || status === 403) {
156
+ return httpFailure("core.http_refused", status);
157
+ }
158
+ if (status === 409 || status === 412) {
159
+ return httpFailure("core.http_conflict", status);
160
+ }
161
+ if (status === 429) {
162
+ return httpFailure("core.http_rate_limited", status);
163
+ }
164
+ if (status >= 500) {
165
+ return httpFailure("core.http_unavailable", status);
166
+ }
167
+ if (status < 200 || status >= 300) {
168
+ return httpFailure("core.http_unexpected_answer", status);
169
+ }
170
+ return null;
171
+ }
172
+
173
+ function httpFailure(code: HttpJsonFailureCode, status: number | null): HttpJsonFailure {
174
+ return { ...err(code), status };
175
+ }
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,19 @@ 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 HttpJsonMethod,
45
+ type HttpJsonWriteMethod,
46
+ type RequestJsonOptions,
47
+ type RequestJsonReadOptions,
48
+ type RequestJsonSchemaOptions,
49
+ type RequestJsonWriteOptions,
50
+ } from "./http-json.js";
30
51
  export { memoizeByContent, type MemoizeByContentOptions, type MemoizedFunction } from "./memoize.js";
31
52
  export { createTestClock, systemClock, type Clock, type TestClock } from "./clock.js";
32
53
  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,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_conflict: "The data changed in the meantime. Please reload it and try again.",
10
+ http_unexpected_answer: "The service sent an answer that could not be read.",
5
11
  },
6
12
  };