shelving 1.182.0 → 1.183.0

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/README.md CHANGED
@@ -1,46 +1,41 @@
1
- # Shelving: toolkit for using data in JavaScript
1
+ # Shelving
2
2
 
3
- [![Semantic Release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg?style=flat)](https://github.com/semantic-release/semantic-release) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) [![Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![GitHub Actions](https://github.com/dhoulb/shelving/workflows/CI/badge.svg?branch=main)](https://github.com/dhoulb/shelving/actions) [![npm](https://img.shields.io/npm/dm/shelving.svg)](https://www.npmjs.com/package/shelving)
3
+ [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) [![Release](https://github.com/dhoulb/shelving/actions/workflows/release.yaml/badge.svg)](https://github.com/dhoulb/shelving/actions/workflows/release.yaml) [![npm](https://img.shields.io/npm/dm/shelving.svg)](https://www.npmjs.com/package/shelving)
4
4
 
5
- **Shelving** is a toolkit for using data in JavaScript and TypeScript, including:
5
+ Shelving is a TypeScript toolkit for working with typed data. At its core it is a schema validation library — every schema has a `validate()` method that returns a typed value or throws a human-readable error. On top of that it provides a database provider abstraction, an API provider abstraction, observable state stores, React integration, and a large set of typed utility functions.
6
6
 
7
- > Note: The `1.x` branch of Shelving is in active development and is not observing semver for breaking changes (from `2.x` onward semver will be followed).
8
-
9
- - Schemas (validation)
10
- - Databases (via providers including in-memory, Firestore, IndexedDB)
11
- - Querying (sorting, filtering, slicing)
12
- - Store (events and state)
13
- - React (hooks and state)
14
- - Helpers (errors, arrays, objects, strings, dates, equality, merging, diffing, cloning, debug)
7
+ > Note: Shelving is in active development and does not yet follow semver.
15
8
 
16
9
  ## Installation
17
10
 
18
- Install via `npm` or `yarn`:
19
-
20
11
  ```sh
21
12
  npm install shelving
22
- yarn add shelving
23
13
  ```
24
14
 
25
- Import from Skypack CDN (the `?dts` enables TypeScript types in Deno):
15
+ Shelving is an ES module. Import from the main package or from individual module subpaths:
26
16
 
27
- ```js
28
- import { Database } from "https://cdn.skypack.dev/shelving";
29
- import { Database } from "https://cdn.skypack.dev/shelving?dts";
17
+ ```ts
18
+ import { STRING, DataSchema } from "shelving"
19
+ import { MemoryDBProvider } from "shelving/db"
30
20
  ```
31
21
 
32
- ## Usage
33
-
34
- Shelving is an [ES module](https://nodejs.org/api/esm.html) supporting `import { Query } from "shelving";` syntax and can be used natively in systems/browsers that support that (e.g. Chrome 61+, Deno, Node 12+).
35
-
36
- Shelving does not include code for CommonJS `require()` imports, so using it in older projects will require transpiling.
37
-
38
22
  ## Modules
39
23
 
40
- Shelving is created from small individual modules which can be imported individually (using e.g. `import { addProp } from "shelving/util/object`). Modules marked with `✅` are also re-exported from the main `"shelving"` module.
41
-
42
- @todo Write these docs!
24
+ | Module | Description |
25
+ |---|---|
26
+ | [schema](modules/schema/README.md) | Schema validation — the foundation of everything |
27
+ | [db](modules/db/README.md) | Database provider abstraction (Collections, providers, queries) |
28
+ | [api](modules/api/README.md) | API provider abstraction (Endpoints, providers, caching) |
29
+ | [store](modules/store/README.md) | Observable state containers, Suspense-compatible |
30
+ | [sequence](modules/sequence/README.md) | Async-iterable utilities (`DeferredSequence`) |
31
+ | [react](modules/react/README.md) | React hooks for stores and sequences |
32
+ | [error](modules/error/README.md) | Typed error classes |
33
+ | [util](modules/util/README.md) | Typed helpers for arrays, objects, strings, data, queries, updates |
34
+ | [markup](modules/markup/README.md) | Markdown renderer for user-facing content |
35
+ | [cloudflare](modules/cloudflare/README.md) | Cloudflare Workers providers (KV, D1) |
36
+ | [firestore](modules/firestore/README.md) | Firestore providers (client, lite, server) |
37
+ | [bun](modules/bun/README.md) | Bun PostgreSQL provider |
43
38
 
44
39
  ## Changelog
45
40
 
46
- See [Releases](https://github.com/dhoulb/shelving/releases)
41
+ See [Releases](https://github.com/dhoulb/shelving/releases).
@@ -6,8 +6,13 @@ import type { Endpoint } from "../endpoint/Endpoint.js";
6
6
  export interface APIProviderOptions {
7
7
  /** The common base URL for all rendered endpoint requests. */
8
8
  readonly url: PossibleURL;
9
- /** Options used for HTTP requests created with `this.getRequest()` and `this.fetch()` */
10
- readonly options?: RequestOptions;
9
+ /**
10
+ * Options used for HTTP requests created with `this.getRequest()` and `this.fetch()`
11
+ * - Omits `signal` because it's not relevant at the provider level.
12
+ */
13
+ readonly options?: Omit<RequestOptions, "signal">;
14
+ /** Timeout in milliseconds, or `undefined` for no timeout. */
15
+ readonly timeout?: number | undefined;
11
16
  }
12
17
  /** Provider for API endpoints rooted at a common base URL. */
13
18
  export declare class APIProvider {
@@ -15,7 +20,9 @@ export declare class APIProvider {
15
20
  readonly url: URLString;
16
21
  /** Default options used for HTTP requests created with `this.getRequest()` and `this.fetch()` */
17
22
  readonly options: RequestOptions;
18
- constructor({ url, options }: APIProviderOptions);
23
+ /** Timeout in milliseconds, or `undefined` for no timeout. */
24
+ readonly timeout: number | undefined;
25
+ constructor({ url, options, timeout }: APIProviderOptions);
19
26
  /**
20
27
  * Render the full final URL for an API request to a given endpoint with a given payload.
21
28
  * - Includes `?query` params if this is a `HEAD` or `GET` request.
@@ -26,9 +33,19 @@ export declare class APIProvider {
26
33
  renderURL<P, R>(endpoint: Endpoint<P, R>, payload: P, caller?: AnyCaller): URL;
27
34
  /**
28
35
  * Create a `Request` that targets this endpoint with a given base URL.
29
- * - Path `{placeholders}` are rendered from the payload.
30
- * - For `GET` and `HEAD`, remaining payload fields are appended as `?query` params.
31
- * - For all other requests, payload is sent as the body.
36
+ *
37
+ * @param payload The payload to embed into the `Request` to send to the endpoint.
38
+ * - Path `{placeholders}` are rendered from `payload`
39
+ * - For `GET` and `HEAD`, remaining `payload` fields are appended as `?query` params.
40
+ * - For all other requests, `payload` is sent as the body.
41
+ *
42
+ * @param options The `RequestOptions` to use when creating the `Request`
43
+ * - Merges `options` with `this.options` to make the final request options.
44
+ *
45
+ * @returns The created request.
46
+ * - Merges `options` with `this.options` to make the final request options.
47
+ * - Includes an `AbortSignal` based on `this.timeout` if it's set to a number in milliseconds.
48
+ * - The timeout `AbortSignal` is merged with any manual signal set in `
32
49
  *
33
50
  * @throws {RequiredError} if this endpoint's path has `{placeholders}` but `payload` is not a data object.
34
51
  * @throws {RequiredError} if this is a `HEAD` or `GET` request but `payload` is not a data object.
@@ -11,9 +11,12 @@ export class APIProvider {
11
11
  url;
12
12
  /** Default options used for HTTP requests created with `this.getRequest()` and `this.fetch()` */
13
13
  options;
14
- constructor({ url, options = {} }) {
14
+ /** Timeout in milliseconds, or `undefined` for no timeout. */
15
+ timeout;
16
+ constructor({ url, options = {}, timeout }) {
15
17
  this.url = requireBaseURL(url, undefined, APIProvider);
16
18
  this.options = options;
19
+ this.timeout = timeout;
17
20
  }
18
21
  /**
19
22
  * Render the full final URL for an API request to a given endpoint with a given payload.
@@ -36,9 +39,19 @@ export class APIProvider {
36
39
  }
37
40
  /**
38
41
  * Create a `Request` that targets this endpoint with a given base URL.
39
- * - Path `{placeholders}` are rendered from the payload.
40
- * - For `GET` and `HEAD`, remaining payload fields are appended as `?query` params.
41
- * - For all other requests, payload is sent as the body.
42
+ *
43
+ * @param payload The payload to embed into the `Request` to send to the endpoint.
44
+ * - Path `{placeholders}` are rendered from `payload`
45
+ * - For `GET` and `HEAD`, remaining `payload` fields are appended as `?query` params.
46
+ * - For all other requests, `payload` is sent as the body.
47
+ *
48
+ * @param options The `RequestOptions` to use when creating the `Request`
49
+ * - Merges `options` with `this.options` to make the final request options.
50
+ *
51
+ * @returns The created request.
52
+ * - Merges `options` with `this.options` to make the final request options.
53
+ * - Includes an `AbortSignal` based on `this.timeout` if it's set to a number in milliseconds.
54
+ * - The timeout `AbortSignal` is merged with any manual signal set in `
42
55
  *
43
56
  * @throws {RequiredError} if this endpoint's path has `{placeholders}` but `payload` is not a data object.
44
57
  * @throws {RequiredError} if this is a `HEAD` or `GET` request but `payload` is not a data object.
@@ -46,17 +59,21 @@ export class APIProvider {
46
59
  getRequest(endpoint, payload, options, caller = this.getRequest) {
47
60
  // Render the path into the base URL.
48
61
  const url = this.renderURL(endpoint, payload, caller);
62
+ // Merge the param options with `this.options`
63
+ // If we have a timeout set, create an `AbortSignal` for it.
64
+ const signal = this.timeout ? AbortSignal.timeout(this.timeout) : null;
65
+ const mergedOptions = mergeRequestOptions({ signal, ...this.options }, options);
49
66
  // HEAD or GET requests have no payload because it was already rendered into the URL as `?query` params.
50
67
  if (isArrayItem(HTTP_HEAD_METHODS, endpoint.method)) {
51
- return getRequest(endpoint.method, url, undefined, options);
68
+ return getRequest(endpoint.method, url, undefined, mergedOptions);
52
69
  }
53
70
  // Placeholders are rendered into the path so get omitted from the body payload.
54
71
  if (endpoint.placeholders.length) {
55
72
  const params = omitProps(payload, ...endpoint.placeholders); // Omit any params that were already embedded as `{placeholders}`
56
- return getRequest(endpoint.method, url, params, options);
73
+ return getRequest(endpoint.method, url, params, mergedOptions);
57
74
  }
58
75
  // No placeholders.
59
- return getRequest(endpoint.method, url, payload, options);
76
+ return getRequest(endpoint.method, url, payload, mergedOptions);
60
77
  }
61
78
  /**
62
79
  * Parse an HTTP `Response` for this endpoint.
@@ -71,7 +88,7 @@ export class APIProvider {
71
88
  return content;
72
89
  }
73
90
  async fetch(endpoint, payload, options, caller = this.fetch) {
74
- const request = this.getRequest(endpoint, payload, mergeRequestOptions(this.options, options), caller);
91
+ const request = this.getRequest(endpoint, payload, options, caller);
75
92
  const response = await fetch(request);
76
93
  return this.parseResponse(endpoint, response, caller);
77
94
  }
@@ -1,7 +1,8 @@
1
1
  import type { AnyCaller } from "../../util/function.js";
2
2
  import { type RequestHandler, type RequestOptions } from "../../util/http.js";
3
3
  import type { AnyEndpoint, Endpoint } from "../endpoint/Endpoint.js";
4
- import { APIProvider, type APIProviderOptions } from "./APIProvider.js";
4
+ import { APIProvider } from "./APIProvider.js";
5
+ import { ThroughAPIProvider } from "./ThroughAPIProvider.js";
5
6
  /** A structured log entry emitted by `MockAPIProvider` for one of its provider operations. */
6
7
  export type MockAPICall = {
7
8
  readonly type: "fetch";
@@ -13,18 +14,14 @@ export type MockAPICall = {
13
14
  readonly result: unknown;
14
15
  };
15
16
  /**
16
- * Construction options for a `MockAPIProvider`
17
- * - Same as options for a normal `APIProvider`, but with an optional URL.
17
+ * Provider that logs API calls without sending network requests.
18
+ * - Extends `ThroughAPIProvider` to delegate request building and response parsing to a source `APIProvider`.
19
+ * - The source provider's `fetch()` is never called — this provider intercepts all fetches and routes them through a `RequestHandler`.
18
20
  */
19
- export interface MockAPIProviderOptions extends Omit<APIProviderOptions, "url"> {
20
- /** Optional URL, defaults to `"https://api.mock.com"` */
21
- url?: APIProviderOptions["url"];
22
- }
23
- /** Provider that logs API calls without sending network requests. */
24
- export declare class MockAPIProvider extends APIProvider {
21
+ export declare class MockAPIProvider extends ThroughAPIProvider {
25
22
  readonly calls: MockAPICall[];
26
23
  readonly handler: RequestHandler;
27
- constructor(handler: RequestHandler, { url, ...options }?: MockAPIProviderOptions);
24
+ constructor(handler: RequestHandler, source?: APIProvider);
28
25
  /**
29
26
  * Log a `fetch()` call without using the network.
30
27
  * - If `getResult` is configured, its return value is returned as-is (no schema validation).
@@ -1,11 +1,16 @@
1
1
  import { mergeRequestOptions } from "../../util/http.js";
2
2
  import { APIProvider } from "./APIProvider.js";
3
- /** Provider that logs API calls without sending network requests. */
4
- export class MockAPIProvider extends APIProvider {
3
+ import { ThroughAPIProvider } from "./ThroughAPIProvider.js";
4
+ /**
5
+ * Provider that logs API calls without sending network requests.
6
+ * - Extends `ThroughAPIProvider` to delegate request building and response parsing to a source `APIProvider`.
7
+ * - The source provider's `fetch()` is never called — this provider intercepts all fetches and routes them through a `RequestHandler`.
8
+ */
9
+ export class MockAPIProvider extends ThroughAPIProvider {
5
10
  calls = [];
6
11
  handler;
7
- constructor(handler, { url = "https://api.mock.com", ...options } = {}) {
8
- super({ url, ...options });
12
+ constructor(handler, source = new APIProvider({ url: "https://api.mock.com" })) {
13
+ super(source);
9
14
  this.handler = handler;
10
15
  }
11
16
  /**
@@ -1,12 +1,6 @@
1
1
  import { type EndpointHandlers } from "../endpoint/util.js";
2
- import { MockAPIProvider, type MockAPIProviderOptions } from "./MockAPIProvider.js";
3
- /**
4
- * Construction options for a `MockAPIProvider`
5
- * - Same as options for a normal `MockAPIProviderOptions`, but with a `context` property for the endpoints.
6
- */
7
- export interface MockEndpointAPIProviderOptions<C = void> extends MockAPIProviderOptions {
8
- context: C;
9
- }
2
+ import type { APIProvider } from "./APIProvider.js";
3
+ import { MockAPIProvider } from "./MockAPIProvider.js";
10
4
  /**
11
5
  * Provider that mocks an API that calls and matches an array of `EndpointHandler` objects returned from `Endpoint.handler()`
12
6
  * - Used to test server-side API code, calls against an API made up of multiple `Endpoint` instances.
@@ -19,5 +13,5 @@ export interface MockEndpointAPIProviderOptions<C = void> extends MockAPIProvide
19
13
  * expect(result).toBe(16);
20
14
  */
21
15
  export declare class MockEndpointAPIProvider<C> extends MockAPIProvider {
22
- constructor(handlers: EndpointHandlers<C>, { context, ...options }: MockEndpointAPIProviderOptions<C>);
16
+ constructor(handlers: EndpointHandlers<C>, context: C, source?: APIProvider);
23
17
  }
@@ -12,7 +12,7 @@ import { MockAPIProvider } from "./MockAPIProvider.js";
12
12
  * expect(result).toBe(16);
13
13
  */
14
14
  export class MockEndpointAPIProvider extends MockAPIProvider {
15
- constructor(handlers, { context, ...options }) {
16
- super(request => handleEndpoints(this.url, handlers, request, context), options);
15
+ constructor(handlers, context, source) {
16
+ super(request => handleEndpoints(this.url, handlers, request, context, this.fetch), source);
17
17
  }
18
18
  }
@@ -11,6 +11,7 @@ export declare class ThroughAPIProvider implements APIProvider {
11
11
  readonly source: APIProvider;
12
12
  get url(): URLString;
13
13
  get options(): RequestOptions;
14
+ get timeout(): number | undefined;
14
15
  constructor(source: APIProvider);
15
16
  renderURL<P, R>(endpoint: Endpoint<P, R>, payload: P, caller?: AnyCaller): URL;
16
17
  getRequest<P, R>(endpoint: Endpoint<P, R>, payload: P, options?: RequestOptions, caller?: AnyCaller): Request;
@@ -10,6 +10,9 @@ export class ThroughAPIProvider {
10
10
  get options() {
11
11
  return this.source.options;
12
12
  }
13
+ get timeout() {
14
+ return this.source.timeout;
15
+ }
13
16
  constructor(source) {
14
17
  this.source = source;
15
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.182.0",
3
+ "version": "1.183.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,14 +9,14 @@
9
9
  "main": "./index.js",
10
10
  "module": "./index.js",
11
11
  "devDependencies": {
12
- "@biomejs/biome": "^2.4.10",
12
+ "@biomejs/biome": "^2.4.11",
13
13
  "@google-cloud/firestore": "^8.3.0",
14
- "@types/bun": "^1.3.11",
14
+ "@types/bun": "^1.3.12",
15
15
  "@types/react": "^19.2.14",
16
16
  "@types/react-dom": "^19.2.3",
17
- "firebase": "^12.11.0",
18
- "react": "^19.2.4",
19
- "react-dom": "^19.2.4",
17
+ "firebase": "^12.12.0",
18
+ "react": "^19.2.5",
19
+ "react-dom": "^19.2.5",
20
20
  "typescript": "^5.9.3"
21
21
  },
22
22
  "peerDependencies": {
package/store/Store.d.ts CHANGED
@@ -12,7 +12,7 @@ export type AnyStore = Store<any>;
12
12
  * @param initial The initial value for the store, a `Promise` that resolves to the initial value, a source `Subscribable` to subscribe to, or another `Store` instance to take the initial value from and subscribe to.
13
13
  * - To set the store to be loading, use the `NONE` constant or a `Promise` value.
14
14
  * - To set the store to an explicit value, use that value or another `Store` instance with a value.
15
- * */
15
+ */
16
16
  export declare class Store<T> implements AsyncIterable<T> {
17
17
  /** Deferred sequence this store uses to issue values as they change. */
18
18
  readonly next: DeferredSequence<T>;
package/store/Store.js CHANGED
@@ -11,7 +11,7 @@ import { getStarter } from "../util/start.js";
11
11
  * @param initial The initial value for the store, a `Promise` that resolves to the initial value, a source `Subscribable` to subscribe to, or another `Store` instance to take the initial value from and subscribe to.
12
12
  * - To set the store to be loading, use the `NONE` constant or a `Promise` value.
13
13
  * - To set the store to an explicit value, use that value or another `Store` instance with a value.
14
- * */
14
+ */
15
15
  export class Store {
16
16
  /** Deferred sequence this store uses to issue values as they change. */
17
17
  next = new DeferredSequence();
@@ -100,8 +100,10 @@ export class Store {
100
100
  this._starter?.start(this);
101
101
  this._iterating++;
102
102
  try {
103
- if (!this.loading)
104
- yield this.value;
103
+ if (this._reason !== undefined)
104
+ throw this._reason;
105
+ if (this._value !== NONE)
106
+ yield this._value;
105
107
  yield* this.next;
106
108
  }
107
109
  finally {
package/util/http.d.ts CHANGED
@@ -79,8 +79,9 @@ export type RequestOptions = Pick<RequestInit, "cache" | "credentials" | "header
79
79
  * Merge provider-level and call-level request options.
80
80
  * - Scalar options from `b` override `a`.
81
81
  * - Header dictionaries are merged so call-level headers override default headers by key.
82
+ * - Abort signals are merged, so either abort signal will cancel the request.
82
83
  */
83
- export declare function mergeRequestOptions({ headers: aHeaders, ...a }?: RequestOptions, { headers: bHeaders, ...b }?: RequestOptions): RequestOptions;
84
+ export declare function mergeRequestOptions({ headers: aHeaders, signal: aSignal, ...a }?: RequestOptions, { headers: bHeaders, signal: bSignal, ...b }?: RequestOptions): RequestOptions;
84
85
  /**
85
86
  * Create a `Request` instance with a valid content type based on the body.
86
87
  *
package/util/http.js CHANGED
@@ -135,9 +135,12 @@ const REQUEST_JSON_OPTIONS = { headers: { "Content-Type": "application/json" } }
135
135
  * Merge provider-level and call-level request options.
136
136
  * - Scalar options from `b` override `a`.
137
137
  * - Header dictionaries are merged so call-level headers override default headers by key.
138
+ * - Abort signals are merged, so either abort signal will cancel the request.
138
139
  */
139
- export function mergeRequestOptions({ headers: aHeaders, ...a } = {}, { headers: bHeaders, ...b } = {}) {
140
- return { ...a, ...b, headers: { ...aHeaders, ...bHeaders } };
140
+ export function mergeRequestOptions({ headers: aHeaders, signal: aSignal, ...a } = {}, { headers: bHeaders, signal: bSignal, ...b } = {}) {
141
+ const headers = { ...aHeaders, ...bHeaders };
142
+ const signal = aSignal && bSignal ? AbortSignal.any([aSignal, bSignal]) : aSignal || bSignal || null;
143
+ return { ...a, ...b, signal, headers };
141
144
  }
142
145
  /**
143
146
  * Create a `Request` instance with a valid content type based on the body.