@zudojs/adapters 1.1.0 → 1.2.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
@@ -97,11 +97,19 @@ A `LifecycleAdapter` adds optional `configure(options)` and `health()` to the
97
97
  base contract. The registry calls them:
98
98
 
99
99
  ```typescript
100
- import { AdapterRegistry, createHealthyHealth, createMockAdapter } from "@zudojs/adapters";
100
+ import {
101
+ AdapterRegistry,
102
+ createHealthyHealth,
103
+ createMockAdapter,
104
+ } from "@zudojs/adapters";
101
105
 
102
106
  const registry = new AdapterRegistry();
103
107
  registry.register(
104
- createMockAdapter({ name: "db", health: () => createHealthyHealth(), configure: () => {} }),
108
+ createMockAdapter({
109
+ name: "db",
110
+ health: () => createHealthyHealth(),
111
+ configure: () => {},
112
+ }),
105
113
  );
106
114
 
107
115
  await registry.configure("db", { poolSize: 10 }); // throws if the adapter has no configure()
@@ -112,6 +120,13 @@ report.adapters.db; // AdapterHealth; throwing, timed-out or aborted checks are
112
120
 
113
121
  Adapters without `health()` are left out of `report.adapters`.
114
122
 
123
+ `healthAll({ retry: { attempts, delay } })` re-runs a check that reports
124
+ `unhealthy`, up to `attempts` tries in total, pausing `delay` ms between
125
+ them; `timeout` bounds each try and an aborted `signal` stops the retries.
126
+ Adapter names are checked on `register()`: `__proto__`, `constructor` and
127
+ `prototype` are refused, because a report keyed by adapter name cannot hold
128
+ them.
129
+
115
130
  ## Transport contracts
116
131
 
117
132
  Type-only interfaces that extend `Adapter` for each transport:
@@ -8,6 +8,7 @@
8
8
  * and `AdapterRegistry.configure()` delegate here.
9
9
  */
10
10
  import { AdapterConfigurationError } from "@zudojs/errors";
11
+ import { withRetry } from "./adapter.retry.js";
11
12
  const RANK = {
12
13
  healthy: 0,
13
14
  degraded: 1,
@@ -16,8 +17,15 @@ const RANK = {
16
17
  function unhealthy(message) {
17
18
  return { status: "unhealthy", message, timestamp: Date.now() };
18
19
  }
19
- /** Runs one health check, bounded by `timeout` and `signal`. */
20
+ /**
21
+ * Runs one health check, bounded by `timeout` and `signal`, retried according
22
+ * to `retry`.
23
+ */
20
24
  async function checkOne(adapter, options) {
25
+ return withRetry(() => attemptCheck(adapter, options), (health) => health.status !== "unhealthy", options);
26
+ }
27
+ /** Runs a single health-check attempt, bounded by `timeout` and `signal`. */
28
+ async function attemptCheck(adapter, options) {
21
29
  if (options.signal?.aborted)
22
30
  return unhealthy("Health check aborted.");
23
31
  let timer;
@@ -54,7 +62,10 @@ async function checkOne(adapter, options) {
54
62
  * instead of failing the whole report.
55
63
  */
56
64
  export async function collectAdapterHealth(entries, options = {}) {
57
- const adapters = {};
65
+ // `Object.create(null)`, not `{}`: assigning `adapters["__proto__"]` on an
66
+ // ordinary object runs the inherited prototype setter, so the entry would
67
+ // vanish from the report and an unhealthy adapter would be invisible.
68
+ const adapters = Object.create(null);
58
69
  await Promise.all(entries
59
70
  .filter(([, adapter]) => typeof adapter.health === "function")
60
71
  .map(async ([name, adapter]) => {
@@ -30,6 +30,10 @@ export declare class AdapterRegistry {
30
30
  * @throws {AdapterConfigurationError} If the adapter name is blank. A name
31
31
  * that normalizes to the empty string is unaddressable — `get("")` is the
32
32
  * only way back to it, and every other blank name collides with it.
33
+ * @throws {AdapterConfigurationError} If the adapter name is a prototype
34
+ * member (`__proto__`, `constructor`, `prototype`). Those names survive the
35
+ * registry's `Map`, but any consumer keying a plain object by adapter name
36
+ * loses or corrupts the entry, so they are refused at the door.
33
37
  * @throws {AdapterAlreadyRegisteredError} If an adapter with the same name is already registered.
34
38
  */
35
39
  register(adapter: Adapter): void;
@@ -13,6 +13,12 @@
13
13
  */
14
14
  import { AdapterAlreadyRegisteredError, AdapterCapabilityMissingError, AdapterConfigurationError, AdapterNotFoundError, } from "@zudojs/errors";
15
15
  import { collectAdapterHealth, configureAdapter } from "./adapter.health.js";
16
+ /** Names that are unsafe as a plain-object key, so no adapter may claim them. */
17
+ const RESERVED_NAMES = new Set([
18
+ "__proto__",
19
+ "constructor",
20
+ "prototype",
21
+ ]);
16
22
  /**
17
23
  * Registry for Zudojs adapters.
18
24
  *
@@ -26,6 +32,10 @@ export class AdapterRegistry {
26
32
  * @throws {AdapterConfigurationError} If the adapter name is blank. A name
27
33
  * that normalizes to the empty string is unaddressable — `get("")` is the
28
34
  * only way back to it, and every other blank name collides with it.
35
+ * @throws {AdapterConfigurationError} If the adapter name is a prototype
36
+ * member (`__proto__`, `constructor`, `prototype`). Those names survive the
37
+ * registry's `Map`, but any consumer keying a plain object by adapter name
38
+ * loses or corrupts the entry, so they are refused at the door.
29
39
  * @throws {AdapterAlreadyRegisteredError} If an adapter with the same name is already registered.
30
40
  */
31
41
  register(adapter) {
@@ -33,6 +43,9 @@ export class AdapterRegistry {
33
43
  if (name === "") {
34
44
  throw new AdapterConfigurationError(String(adapter.name), new Error("Adapter name cannot be blank."));
35
45
  }
46
+ if (RESERVED_NAMES.has(name)) {
47
+ throw new AdapterConfigurationError(String(adapter.name), new Error(`Adapter name "${name}" is reserved.`));
48
+ }
36
49
  if (this.adapters.has(name)) {
37
50
  throw new AdapterAlreadyRegisteredError(name);
38
51
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @zudojs/adapters/adapter
3
+ *
4
+ * Retry support for adapter operations.
5
+ *
6
+ * `AdapterOperationOptions.retry` was part of the contract with nothing that
7
+ * read it, so a caller asking for three attempts silently got one.
8
+ */
9
+ import type { AdapterOperationOptions } from "../lifecycle/lifecycle.type.js";
10
+ /**
11
+ * Total attempts requested, clamped to at least one.
12
+ *
13
+ * A missing, non-finite, fractional or non-positive `attempts` means a single
14
+ * attempt: a retry policy nobody can express as "run fewer than once".
15
+ */
16
+ export declare function retryAttempts(options: AdapterOperationOptions): number;
17
+ /** Delay between attempts in milliseconds; negative and non-finite mean none. */
18
+ export declare function retryDelay(options: AdapterOperationOptions): number;
19
+ /** Waits `ms`, resolving early when `signal` aborts so a retry cannot outlive it. */
20
+ export declare function wait(ms: number, signal?: AbortSignal): Promise<void>;
21
+ /**
22
+ * Runs `attempt` until `accept` approves its result or the attempt budget from
23
+ * `options.retry` is spent, returning the last result either way.
24
+ *
25
+ * An aborted signal stops further attempts immediately — the caller has already
26
+ * said it no longer wants the answer.
27
+ *
28
+ * @typeParam T - The attempt's result type.
29
+ * @param attempt - The operation to run; it must not throw.
30
+ * @param accept - Whether a result is final.
31
+ * @param options - Operation options carrying the retry policy.
32
+ * @returns The first accepted result, or the last one produced.
33
+ */
34
+ export declare function withRetry<T>(attempt: () => Promise<T>, accept: (result: T) => boolean, options: AdapterOperationOptions): Promise<T>;
35
+ //# sourceMappingURL=adapter.retry.d.ts.map
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @zudojs/adapters/adapter
3
+ *
4
+ * Retry support for adapter operations.
5
+ *
6
+ * `AdapterOperationOptions.retry` was part of the contract with nothing that
7
+ * read it, so a caller asking for three attempts silently got one.
8
+ */
9
+ /**
10
+ * Total attempts requested, clamped to at least one.
11
+ *
12
+ * A missing, non-finite, fractional or non-positive `attempts` means a single
13
+ * attempt: a retry policy nobody can express as "run fewer than once".
14
+ */
15
+ export function retryAttempts(options) {
16
+ const attempts = options.retry?.attempts;
17
+ if (typeof attempts !== "number" || !Number.isFinite(attempts))
18
+ return 1;
19
+ return Math.max(1, Math.floor(attempts));
20
+ }
21
+ /** Delay between attempts in milliseconds; negative and non-finite mean none. */
22
+ export function retryDelay(options) {
23
+ const delay = options.retry?.delay;
24
+ if (typeof delay !== "number" || !Number.isFinite(delay))
25
+ return 0;
26
+ return Math.max(0, delay);
27
+ }
28
+ /** Waits `ms`, resolving early when `signal` aborts so a retry cannot outlive it. */
29
+ export function wait(ms, signal) {
30
+ return new Promise((resolve) => {
31
+ const finish = () => {
32
+ clearTimeout(timer);
33
+ signal?.removeEventListener("abort", finish);
34
+ resolve();
35
+ };
36
+ const timer = setTimeout(finish, ms);
37
+ signal?.addEventListener("abort", finish, { once: true });
38
+ // A signal that aborted before the listener was attached never fires it.
39
+ if (signal?.aborted === true)
40
+ finish();
41
+ });
42
+ }
43
+ /**
44
+ * Runs `attempt` until `accept` approves its result or the attempt budget from
45
+ * `options.retry` is spent, returning the last result either way.
46
+ *
47
+ * An aborted signal stops further attempts immediately — the caller has already
48
+ * said it no longer wants the answer.
49
+ *
50
+ * @typeParam T - The attempt's result type.
51
+ * @param attempt - The operation to run; it must not throw.
52
+ * @param accept - Whether a result is final.
53
+ * @param options - Operation options carrying the retry policy.
54
+ * @returns The first accepted result, or the last one produced.
55
+ */
56
+ export async function withRetry(attempt, accept, options) {
57
+ const attempts = retryAttempts(options);
58
+ const delay = retryDelay(options);
59
+ const aborted = () => options.signal?.aborted === true;
60
+ let result = await attempt();
61
+ for (let remaining = attempts - 1; remaining > 0; remaining--) {
62
+ if (accept(result))
63
+ return result;
64
+ if (aborted())
65
+ return result;
66
+ if (delay > 0)
67
+ await wait(delay, options.signal);
68
+ if (aborted())
69
+ return result;
70
+ result = await attempt();
71
+ }
72
+ return result;
73
+ }
74
+ //# sourceMappingURL=adapter.retry.js.map
@@ -25,7 +25,14 @@ export interface AdapterOperationOptions {
25
25
  readonly signal?: AbortSignal;
26
26
  /** Timeout in milliseconds. */
27
27
  readonly timeout?: number;
28
- /** Retry configuration. */
28
+ /**
29
+ * Retry configuration.
30
+ *
31
+ * `attempts` is the total number of tries, not the number of extra ones:
32
+ * `1` (or anything below it) runs the operation once. `delay` is the pause
33
+ * in milliseconds between tries, ended early by `signal`. A health check is
34
+ * retried only while it reports `"unhealthy"`; `timeout` bounds each try.
35
+ */
29
36
  readonly retry?: {
30
37
  readonly attempts: number;
31
38
  readonly delay?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/adapters",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Boundary layer between Zudojs and external platforms — adapter contracts, registry, capabilities, and transport abstractions.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -27,7 +27,7 @@
27
27
  "node": ">=24.0.0"
28
28
  },
29
29
  "dependencies": {
30
- "@zudojs/errors": "1.1.0"
30
+ "@zudojs/errors": "1.2.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "typescript": "7.0.2",