@particle-academy/fancy-connector-core 0.3.1 → 0.4.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/CHANGELOG.md CHANGED
@@ -6,6 +6,48 @@ All notable changes to `fancy-connector-core` are documented here, in
6
6
  **This package is pre-1.0, so breaking changes land in MINOR releases.** The
7
7
  version number is not a promise it can keep yet; the entries below are.
8
8
 
9
+ ## [0.4.0] - 2026-09-12
10
+
11
+ ### Fixed
12
+
13
+ - **An idempotency key was fitted to the catalogue CEILING, never to the
14
+ provider's own limit.** `MAX_KEY_LENGTH` / `MAX_IDEMPOTENCY_KEY_LENGTH` is 255
15
+ — the widest any provider in the catalogue accepts. A provider declares its
16
+ own, and the connector index carries it as `idempotencyMaxLength`; Discord's
17
+ `discord_message` declares **25**.
18
+
19
+ So a legitimate engine-derived key — `lane_<16 hex>:subject`, 29 characters —
20
+ came back unshortened and the connector's own validation refused it:
21
+ `message_create: idempotencyKey must be at most 25 characters`. The run failed
22
+ at the node, and the only workaround available to a host was choosing a
23
+ shorter run identity, which is a workflow-authoring decision being forced by a
24
+ string length in a library.
25
+
26
+ The subtle part is that nothing was malformed: the key was correct and the
27
+ validation was correct. This package simply never asked how long the key was
28
+ allowed to be.
29
+
30
+ `keyFor(..., maxLength:)` in PHP and `{ maxLength }` in `IdempotencyOptions`
31
+ now carry the provider's limit, and the smaller of it and the ceiling always
32
+ applies — a descriptor claiming more than any provider accepts is one to
33
+ distrust, not obey. The digest still makes the shortened key **stable across
34
+ attempts**, which is the entire point of one: a key that varied per attempt
35
+ would defeat the dedupe it exists to provide.
36
+
37
+ A limit too small to carry `<head>~<digest>` yields the digest alone. That
38
+ loses the greppable prefix, which is a real cost — but a key that is hard to
39
+ trace still dedupes, while the alternative is a negative slice length.
40
+
41
+ **Both runtimes had this, identically**, so the parity suite comparing them
42
+ stayed green throughout. Agreement is not correctness, and that is the whole
43
+ reason this needed an outside report to surface: it came from the connector
44
+ lab, which hit it on Discord and worked around it by keeping its run key
45
+ artificially short.
46
+
47
+ Not a breaking change — the parameter is optional and omitting it preserves
48
+ the previous behaviour exactly. It lands as a MINOR because callers that
49
+ should pass a limit now can, and the connector emitters will.
50
+
9
51
  ## [0.3.1] - 2026-08-20
10
52
 
11
53
  ### Fixed
package/dist/index.cjs CHANGED
@@ -937,7 +937,7 @@ function idempotencyKeyFor(ctx, nodeId, options = {}) {
937
937
  const windowSeconds = options.windowSeconds === void 0 ? DEFAULT_IDEMPOTENCY_WINDOW_SECONDS : options.windowSeconds;
938
938
  if (!identity) {
939
939
  const runKey = runKeyFrom(ctx);
940
- return runKey === null ? null : fit(`${runKey}:${nodeId}`);
940
+ return runKey === null ? null : fit(`${runKey}:${nodeId}`, options.maxLength);
941
941
  }
942
942
  if (!identity.isReplaySafe(windowSeconds, options.now ?? /* @__PURE__ */ new Date())) {
943
943
  throw new ConnectorIdempotencyExpiredError(
@@ -949,13 +949,15 @@ function idempotencyKeyFor(ctx, nodeId, options = {}) {
949
949
  }
950
950
  );
951
951
  }
952
- return fit(identity.stepKey(nodeId, options.occurrence));
952
+ return fit(identity.stepKey(nodeId, options.occurrence), options.maxLength);
953
953
  }
954
954
  var NO_IDEMPOTENCY_KEY_WARNING = "no run identity available, so this write is being sent WITHOUT an idempotency key. A retry would repeat the effect rather than recover from it \u2014 the node is marked unsafe-to-replay, so a durable runner will give it one attempt. Durable runs supply an identity automatically; for a synchronous run, pass `run` in RunOptions.";
955
- function fit(key) {
956
- if (key.length <= MAX_IDEMPOTENCY_KEY_LENGTH) return key;
955
+ function fit(key, maxLength) {
956
+ const limit = maxLength === void 0 || maxLength === null ? MAX_IDEMPOTENCY_KEY_LENGTH : Math.max(1, Math.min(maxLength, MAX_IDEMPOTENCY_KEY_LENGTH));
957
+ if (key.length <= limit) return key;
957
958
  const digest = fnv1a(key);
958
- const head = key.slice(0, MAX_IDEMPOTENCY_KEY_LENGTH - digest.length - 1);
959
+ if (limit <= digest.length + 1) return digest.slice(0, limit);
960
+ const head = key.slice(0, limit - digest.length - 1);
959
961
  return `${head}~${digest}`;
960
962
  }
961
963
  function fnv1a(value) {