@onlyworlds/sdk 4.2.0 → 4.3.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/AGENTS.md CHANGED
@@ -37,7 +37,13 @@ against live data). The v1 surface (`OnlyWorldsClient`, the `ElementType` enum)
37
37
  A v4 or v7 id you supply is accepted. **Never sort elements by id**: worlds mix v7, v4
38
38
  and legacy `06x…` ids (nibble 7 too, but seconds-first). For creation order use
39
39
  `created_at`; `change_seq` is last-write order, not creation.
40
- A PUT or bulk item whose id belongs to **another world** returns 409 `id_conflict`.
40
+ A create with an id that already exists, or a PUT whose id belongs to **another world**,
41
+ returns 409 `id_conflict` (`err.isIdConflict`; retrying won't help). `/bulk` never throws:
42
+ check each slot for `status: 409` with `error.code: 'id_conflict'`. A different 409, `idempotency_error`, means an
43
+ Idempotency-Key was reused with another body (`err.isIdempotencyConflict`).
44
+ - Under load keel answers 503 `server_busy` with `Retry-After` (`err.isBusy`, `err.retryAfter`
45
+ in seconds). The client does not retry for you; back off and retry yourself. Works the same
46
+ in browsers.
41
47
  - A string holding an unpaired surrogate (text cut mid-emoji) is a 422 naming the field.
42
48
  Slice strings by code point, not by UTF-16 unit.
43
49
  - Colour carries the element's FAMILY (`elementColor(type, mode)`); the icon
package/CHANGELOG.md CHANGED
@@ -5,7 +5,34 @@ earlier history lives in git log only.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
- ## [4.2.0] — staged 2026-09-28, not yet published
8
+ ## [4.3.0] — 2026-09-28
9
+
10
+ Errors you can act on: the two 409s told apart, and the busy server's `Retry-After`, in Node and
11
+ browsers. One getter narrows (see Fixed); nothing is removed.
12
+
13
+ ### Fixed
14
+ - **`OwApiError.isIdempotencyConflict` no longer claims every 409.** keel sends two: 409
15
+ `idempotency_error` (a key reused with a different body) and 409 `id_conflict` (the id is
16
+ taken: a create with an existing id since D39, or a PUT / bulk id held by another world since
17
+ D70). The getter was true for both, so an id conflict read as a key problem, and retrying with
18
+ a fresh key could never help. It now matches `idempotency_error` only, and the new
19
+ **`isIdConflict`** names the other. A caller who relied on the old catch-all for `id_conflict`
20
+ should switch to `isIdConflict`. A 409 with no code (a proxy, a non-JSON body) now reads
21
+ false for both. `/bulk` is unaffected: it answers 200 and reports a per-item 409 in the slot.
22
+
23
+ ### Added
24
+ - **`OwApiError.retryAfter`** (seconds, or null) is parsed from `Retry-After`, as a delay or an
25
+ HTTP date, and **`isBusy`** marks keel's 503 `server_busy` (admission control, keel D71: six
26
+ requests at once per worker, up to 10 s of queueing, then 503 with `Retry-After: 5`). The client
27
+ still never retries on its own; it gives callers what they need to back off. A malformed
28
+ `Retry-After` is null, never "retry now". **Works in browsers too** since keel `4949406`
29
+ (2026-09-28): every response exposes `Retry-After`, `Idempotent-Replay` and
30
+ `X-OW-Schema-Version` over CORS (probed from an allowed origin), and keel reports that the
31
+ admission gate's 503 now carries CORS headers, so a busy server reads as busy, not as
32
+ `OwNetworkError` (keel's word; the 503 can't be triggered on demand to probe). Six tests; the
33
+ four core ones watched failing on the previous `errors.ts`.
34
+
35
+ ## [4.2.0] — 2026-09-28
9
36
 
10
37
  Ids, honest filter docs, and the schema repin. Nothing is removed; no call needs to change.
11
38
 
package/dist/index.d.ts CHANGED
@@ -2014,12 +2014,34 @@ declare class OwApiError extends Error {
2014
2014
  readonly docUrl: string | null;
2015
2015
  /** Raw parsed envelope (or body text when the body wasn't JSON). */
2016
2016
  readonly detail: unknown;
2017
- constructor(status: number, code: string | null, message: string, docUrl: string | null, detail: unknown, type?: string | null, param?: string | null);
2017
+ /**
2018
+ * Seconds to wait before retrying, from the `Retry-After` header (a delay in
2019
+ * seconds or an HTTP date), else null. keel sends it on 503 `server_busy`
2020
+ * (admission control, D71) and 429 `rate_limited`. This client never retries
2021
+ * on its own; the value is here so callers can back off politely.
2022
+ * Readable in browsers too: keel exposes `Retry-After` over CORS and its
2023
+ * admission gate's 503 carries CORS headers (keel `4949406`, 2026-09-28).
2024
+ */
2025
+ readonly retryAfter: number | null;
2026
+ constructor(status: number, code: string | null, message: string, docUrl: string | null, detail: unknown, type?: string | null, param?: string | null, retryAfter?: number | null);
2018
2027
  get isAuthError(): boolean;
2019
2028
  /** 422s/400s name the offending param/field -- typos error loudly platform-wide. */
2020
2029
  get isValidationError(): boolean;
2021
- /** Same Idempotency-Key replayed with a different payload. */
2030
+ /**
2031
+ * Same Idempotency-Key replayed with a different payload (409 `idempotency_error`).
2032
+ * Through 4.2.0 this was true for ANY 409, which misread an id conflict as a key
2033
+ * conflict: keel also answers 409 `id_conflict` (see isIdConflict).
2034
+ */
2022
2035
  get isIdempotencyConflict(): boolean;
2036
+ /**
2037
+ * The id is already taken (409 `id_conflict`): a create with an id that exists
2038
+ * (keel D39), or a PUT whose id belongs to another world (D70). Retrying will not
2039
+ * help; the id is the problem. `/bulk` never throws for this: it answers 200 with a
2040
+ * per-item slot `{status: 409, error: {code: 'id_conflict'}}` — check the slot.
2041
+ */
2042
+ get isIdConflict(): boolean;
2043
+ /** keel's admission control turned the request away (503 `server_busy`); see retryAfter. */
2044
+ get isBusy(): boolean;
2023
2045
  }
2024
2046
  /** Network-level failure (fetch rejected) -- no envelope to parse. */
2025
2047
  declare class OwNetworkError extends Error {
@@ -2029,7 +2051,7 @@ declare class OwNetworkError extends Error {
2029
2051
  /** Parse a wire envelope into OwApiError parts (exported for the error type-tests). */
2030
2052
  /** Parse the platform ERROR envelope into an OwApiError. (Renamed from parseEnvelope in 4.0 —
2031
2053
  * distinct from the world-export envelope, which is a different artifact entirely.) */
2032
- declare function parseErrorEnvelope(status: number, body: unknown): OwApiError;
2054
+ declare function parseErrorEnvelope(status: number, body: unknown, retryAfter?: number | null): OwApiError;
2033
2055
  /** Build an OwApiError from a non-2xx response, tolerating non-JSON bodies. */
2034
2056
  declare function errorFromResponse(res: Response): Promise<OwApiError>;
2035
2057
 
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/v2/errors.ts
2
2
  var OwApiError = class extends Error {
3
- constructor(status, code, message, docUrl, detail, type = null, param = null) {
3
+ constructor(status, code, message, docUrl, detail, type = null, param = null, retryAfter = null) {
4
4
  super(message);
5
5
  this.name = "OwApiError";
6
6
  this.status = status;
@@ -9,6 +9,7 @@ var OwApiError = class extends Error {
9
9
  this.param = param;
10
10
  this.docUrl = docUrl;
11
11
  this.detail = detail;
12
+ this.retryAfter = retryAfter;
12
13
  }
13
14
  get isAuthError() {
14
15
  return this.code === "invalid_credentials" || this.code === "key_revoked" || this.code === "world_gone";
@@ -17,9 +18,26 @@ var OwApiError = class extends Error {
17
18
  get isValidationError() {
18
19
  return this.status === 422 || this.status === 400;
19
20
  }
20
- /** Same Idempotency-Key replayed with a different payload. */
21
+ /**
22
+ * Same Idempotency-Key replayed with a different payload (409 `idempotency_error`).
23
+ * Through 4.2.0 this was true for ANY 409, which misread an id conflict as a key
24
+ * conflict: keel also answers 409 `id_conflict` (see isIdConflict).
25
+ */
21
26
  get isIdempotencyConflict() {
22
- return this.status === 409;
27
+ return this.status === 409 && this.code === "idempotency_error";
28
+ }
29
+ /**
30
+ * The id is already taken (409 `id_conflict`): a create with an id that exists
31
+ * (keel D39), or a PUT whose id belongs to another world (D70). Retrying will not
32
+ * help; the id is the problem. `/bulk` never throws for this: it answers 200 with a
33
+ * per-item slot `{status: 409, error: {code: 'id_conflict'}}` — check the slot.
34
+ */
35
+ get isIdConflict() {
36
+ return this.status === 409 && this.code === "id_conflict";
37
+ }
38
+ /** keel's admission control turned the request away (503 `server_busy`); see retryAfter. */
39
+ get isBusy() {
40
+ return this.status === 503 && this.code === "server_busy";
23
41
  }
24
42
  };
25
43
  var OwNetworkError = class extends Error {
@@ -29,7 +47,7 @@ var OwNetworkError = class extends Error {
29
47
  this.cause2 = cause;
30
48
  }
31
49
  };
32
- function parseErrorEnvelope(status, body) {
50
+ function parseErrorEnvelope(status, body, retryAfter = null) {
33
51
  const env = body && typeof body === "object" ? body : {};
34
52
  const nested = typeof env.error === "object" && env.error !== null ? env.error : void 0;
35
53
  const code = env.code ?? nested?.code ?? (typeof env.error === "string" ? env.error : null) ?? null;
@@ -37,7 +55,16 @@ function parseErrorEnvelope(status, body) {
37
55
  const param = env.param ?? nested?.param ?? null;
38
56
  const docUrl = env.doc_url ?? nested?.doc_url ?? null;
39
57
  const message = env.message ?? nested?.message ?? (typeof env.detail === "string" ? env.detail : void 0) ?? `OnlyWorlds API error ${status}${code ? ` (${code})` : ""}`;
40
- return new OwApiError(status, code, message, docUrl, body, type, param);
58
+ return new OwApiError(status, code, message, docUrl, body, type, param, retryAfter);
59
+ }
60
+ function parseRetryAfter(value, now = Date.now()) {
61
+ if (value == null) return null;
62
+ const v = value.trim();
63
+ if (/^\d+$/.test(v)) return Number(v);
64
+ if (!/[A-Za-z]/.test(v)) return null;
65
+ const when = Date.parse(v);
66
+ if (Number.isNaN(when)) return null;
67
+ return Math.max(0, Math.ceil((when - now) / 1e3));
41
68
  }
42
69
  async function errorFromResponse(res) {
43
70
  let body = null;
@@ -48,7 +75,7 @@ async function errorFromResponse(res) {
48
75
  } catch {
49
76
  body = text || null;
50
77
  }
51
- return parseErrorEnvelope(res.status, body);
78
+ return parseErrorEnvelope(res.status, body, parseRetryAfter(res.headers?.get?.("Retry-After") ?? null));
52
79
  }
53
80
 
54
81
  // src/v2/keys.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlyworlds/sdk",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "TypeScript SDK for the OnlyWorlds API - build world-building applications with type safety",
5
5
  "type": "module",
6
6
  "exports": {