@cross-deck/node 1.6.0 → 1.7.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
@@ -4,6 +4,41 @@ All notable changes to `@cross-deck/node` will be documented here. The
4
4
  format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.7.0] — 2026-06-11
8
+
9
+ **PARK on version-rejection — events are held, never dropped.** A third
10
+ event-queue outcome for the day the server stops accepting an outdated event
11
+ format. Purely additive; no public API change.
12
+
13
+ **Added:**
14
+
15
+ - **PARK (HTTP `426` / `sdk_version_unsupported`).** A version-rejection is now
16
+ recognised as its own outcome — distinct from retry (transient) and drop
17
+ (invalid): the data is good, only the wire dialect is stale. The queue
18
+ **holds** the events (folded to the buffer front, FIFO-capped at 1000),
19
+ **hushes** (stops flushing a known-too-old payload), **signals** once (one
20
+ `console.warn` + a typed `sdk.parked` debug event), and delivers on restart
21
+ after you upgrade. Node's queue is in-memory, so a process restart *before*
22
+ upgrade clears the held events — an opt-in disk-backed queue is on the
23
+ roadmap; the messaging says exactly this, never more.
24
+ - **`sdk_version_unsupported`** added to the error-codes catalogue with
25
+ remediation, and `version_error` to `CrossdeckErrorType`. `CrossdeckError`
26
+ carries `minVersion` / `surface` from the 426 body. New `onParked` callback.
27
+
28
+ **Fixed (no public API change):**
29
+
30
+ - The empty-input contract is now codified cross-SDK as
31
+ `invalid-input-rejected-natively`: `track("")` / `aliasIdentity` with a
32
+ missing `userId` reject at the call site by throwing a typed `CrossdeckError`
33
+ (`missing_event_name` / `missing_user_id`) and never reach the wire — the
34
+ Node/JS idiom of the invariant *"invalid input never crashes the app."* No
35
+ behaviour change; the guarantee is now documented and bundled.
36
+ - Standalone-build fix: the `contract-failed` schema-lock test now reads the
37
+ bundled contract (`_contracts-bundled.ts`) instead of the monorepo
38
+ `contracts/` path, so the published-mirror release build no longer fails.
39
+
40
+ See https://cross-deck.com/docs/sdk-event-durability/ for the durability contract.
41
+
7
42
  ## [1.6.0] — 2026-06-10
8
43
 
9
44
  Event Envelope v1 conformance — server-enforced contract (spec
@@ -1,4 +1,4 @@
1
- import { w as CrossdeckServer } from '../crossdeck-server-C1Ue0rv4.mjs';
1
+ import { w as CrossdeckServer } from '../crossdeck-server-DYawt4eT.mjs';
2
2
  import 'node:events';
3
3
 
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { w as CrossdeckServer } from '../crossdeck-server-C1Ue0rv4.js';
1
+ import { w as CrossdeckServer } from '../crossdeck-server-DYawt4eT.js';
2
2
  import 'node:events';
3
3
 
4
4
  /**
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
 
3
- type CrossdeckErrorType = "authentication_error" | "permission_error" | "invalid_request_error" | "rate_limit_error" | "internal_error" | "network_error" | "configuration_error";
3
+ type CrossdeckErrorType = "authentication_error" | "permission_error" | "invalid_request_error" | "rate_limit_error" | "version_error" | "internal_error" | "network_error" | "configuration_error";
4
4
  interface CrossdeckErrorPayload {
5
5
  type: CrossdeckErrorType;
6
6
  /**
@@ -19,6 +19,14 @@ interface CrossdeckErrorPayload {
19
19
  requestId?: string;
20
20
  status?: number;
21
21
  retryAfterMs?: number;
22
+ /**
23
+ * Required SDK version floor — populated only on a `426 Upgrade Required`
24
+ * / `sdk_version_unsupported` response, so the queue's PARK message names
25
+ * the exact version to update to.
26
+ */
27
+ minVersion?: string;
28
+ /** SDK surface the rejection applies to (web/node/swift/…), on a 426. */
29
+ surface?: string;
22
30
  }
23
31
  declare class CrossdeckError extends Error {
24
32
  readonly type: CrossdeckErrorType;
@@ -26,6 +34,8 @@ declare class CrossdeckError extends Error {
26
34
  readonly requestId?: string;
27
35
  readonly status?: number;
28
36
  readonly retryAfterMs?: number;
37
+ readonly minVersion?: string;
38
+ readonly surface?: string;
29
39
  constructor(payload: CrossdeckErrorPayload);
30
40
  /**
31
41
  * JSON representation suitable for structured loggers. Without this,
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
 
3
- type CrossdeckErrorType = "authentication_error" | "permission_error" | "invalid_request_error" | "rate_limit_error" | "internal_error" | "network_error" | "configuration_error";
3
+ type CrossdeckErrorType = "authentication_error" | "permission_error" | "invalid_request_error" | "rate_limit_error" | "version_error" | "internal_error" | "network_error" | "configuration_error";
4
4
  interface CrossdeckErrorPayload {
5
5
  type: CrossdeckErrorType;
6
6
  /**
@@ -19,6 +19,14 @@ interface CrossdeckErrorPayload {
19
19
  requestId?: string;
20
20
  status?: number;
21
21
  retryAfterMs?: number;
22
+ /**
23
+ * Required SDK version floor — populated only on a `426 Upgrade Required`
24
+ * / `sdk_version_unsupported` response, so the queue's PARK message names
25
+ * the exact version to update to.
26
+ */
27
+ minVersion?: string;
28
+ /** SDK surface the rejection applies to (web/node/swift/…), on a 426. */
29
+ surface?: string;
22
30
  }
23
31
  declare class CrossdeckError extends Error {
24
32
  readonly type: CrossdeckErrorType;
@@ -26,6 +34,8 @@ declare class CrossdeckError extends Error {
26
34
  readonly requestId?: string;
27
35
  readonly status?: number;
28
36
  readonly retryAfterMs?: number;
37
+ readonly minVersion?: string;
38
+ readonly surface?: string;
29
39
  constructor(payload: CrossdeckErrorPayload);
30
40
  /**
31
41
  * JSON representation suitable for structured loggers. Without this,
package/dist/index.cjs CHANGED
@@ -66,6 +66,8 @@ var CrossdeckError = class _CrossdeckError extends Error {
66
66
  requestId;
67
67
  status;
68
68
  retryAfterMs;
69
+ minVersion;
70
+ surface;
69
71
  constructor(payload) {
70
72
  super(payload.message);
71
73
  this.name = "CrossdeckError";
@@ -74,6 +76,8 @@ var CrossdeckError = class _CrossdeckError extends Error {
74
76
  this.requestId = payload.requestId;
75
77
  this.status = payload.status;
76
78
  this.retryAfterMs = payload.retryAfterMs;
79
+ this.minVersion = payload.minVersion;
80
+ this.surface = payload.surface;
77
81
  Object.setPrototypeOf(this, _CrossdeckError.prototype);
78
82
  }
79
83
  /**
@@ -95,6 +99,8 @@ var CrossdeckError = class _CrossdeckError extends Error {
95
99
  requestId: this.requestId,
96
100
  status: this.status,
97
101
  retryAfterMs: this.retryAfterMs,
102
+ minVersion: this.minVersion,
103
+ surface: this.surface,
98
104
  stack: this.stack
99
105
  };
100
106
  }
@@ -185,7 +191,10 @@ async function crossdeckErrorFromResponse(res) {
185
191
  message: envelope.message ?? `HTTP ${res.status}`,
186
192
  requestId: envelope.request_id ?? requestId,
187
193
  status: res.status,
188
- retryAfterMs
194
+ retryAfterMs,
195
+ // PARK metadata, present only on a 426 / sdk_version_unsupported body.
196
+ minVersion: typeof envelope.minVersion === "string" ? envelope.minVersion : void 0,
197
+ surface: typeof envelope.surface === "string" ? envelope.surface : void 0
189
198
  });
190
199
  }
191
200
  return makeCrossdeckError({
@@ -378,7 +387,7 @@ function byteLength(s) {
378
387
  var https = __toESM(require("https"));
379
388
 
380
389
  // src/_version.ts
381
- var SDK_VERSION = "1.6.0";
390
+ var SDK_VERSION = "1.7.0";
382
391
  var SDK_NAME = "@cross-deck/node";
383
392
 
384
393
  // src/_diagnostic-telemetry.ts
@@ -398,7 +407,12 @@ var DIAGNOSTIC_TELEMETRY_ALLOWED_KEYS = /* @__PURE__ */ new Set([
398
407
  "run_id",
399
408
  "test_file",
400
409
  "test_name",
401
- "device_class"
410
+ "device_class",
411
+ // verification_phase — categorical `boot` / `hot_path` bucket set by the
412
+ // runtime contract verifier layer. Declared in the shared diagnostics
413
+ // contract (contracts/diagnostics/contract-failed-payload-schema-lock.json)
414
+ // and carried by web/swift; node had drifted by omitting it.
415
+ "verification_phase"
402
416
  ]);
403
417
  function filterDiagnosticPayload(payload) {
404
418
  const filtered = {};
@@ -891,6 +905,16 @@ var EventQueue = class {
891
905
  cancelTimer = null;
892
906
  firstFlushFired = false;
893
907
  nextRetryAt = null;
908
+ /**
909
+ * PARK state (HTTP 426 / `sdk_version_unsupported`). Once parked, the
910
+ * queue stops flushing — retrying a known-too-old payload only wastes
911
+ * bandwidth and drips pointless rejects into the server logs until the
912
+ * process restarts on an upgraded SDK. In-memory: a fresh process starts
913
+ * unparked, retries once, and either delivers (upgraded) or re-parks.
914
+ */
915
+ parked = false;
916
+ /** One developer-facing console warning per process — never per-event spam. */
917
+ parkWarned = false;
894
918
  retry;
895
919
  /**
896
920
  * Stable Idempotency-Key for the current in-flight batch. Minted
@@ -943,6 +967,7 @@ var EventQueue = class {
943
967
  * this one settles. Strict ordering preserved.
944
968
  */
945
969
  async flush() {
970
+ if (this.parked) return null;
946
971
  let batch;
947
972
  let batchId;
948
973
  if (this.pendingBatch !== null && this.pendingBatchId !== null) {
@@ -990,6 +1015,28 @@ var EventQueue = class {
990
1015
  } catch (err) {
991
1016
  const message = err instanceof Error ? err.message : String(err);
992
1017
  this.lastError = message;
1018
+ if (isVersionRejected(err)) {
1019
+ this.parked = true;
1020
+ this.buffer = [...batch, ...this.buffer];
1021
+ if (this.buffer.length > HARD_BUFFER_CAP) {
1022
+ const overflow = this.buffer.length - HARD_BUFFER_CAP;
1023
+ this.buffer.splice(0, overflow);
1024
+ this.dropped += overflow;
1025
+ }
1026
+ this.pendingBatch = null;
1027
+ this.pendingBatchId = null;
1028
+ this.inFlight -= batch.length;
1029
+ this.cfg.onBufferChange?.(this.buffer.length);
1030
+ const minVersion = versionRejectionFloor(err);
1031
+ if (!this.parkWarned) {
1032
+ this.parkWarned = true;
1033
+ console.warn(
1034
+ `[Crossdeck] SDK outdated \u2014 the server is no longer accepting this version's event format. Events are PARKED in memory (held, not lost across this process) and will deliver once you update @cross-deck/node${minVersion ? ` to >= ${minVersion}` : ""} and restart. A restart before upgrade clears them \u2014 configure a disk queue for cross-restart durability.`
1035
+ );
1036
+ }
1037
+ this.cfg.onParked?.({ minVersion, surface: versionRejectionSurface(err) });
1038
+ return null;
1039
+ }
993
1040
  if (isPermanent4xx(err)) {
994
1041
  const droppedCount = batch.length;
995
1042
  this.pendingBatch = null;
@@ -1088,8 +1135,22 @@ function isPermanent4xx(err) {
1088
1135
  if (typeof status !== "number" || !Number.isFinite(status)) return false;
1089
1136
  if (status < 400 || status >= 500) return false;
1090
1137
  if (status === 408 || status === 429) return false;
1138
+ if (status === 426) return false;
1091
1139
  return true;
1092
1140
  }
1141
+ function isVersionRejected(err) {
1142
+ if (!err || typeof err !== "object") return false;
1143
+ if (err.status === 426) return true;
1144
+ return err.code === "sdk_version_unsupported";
1145
+ }
1146
+ function versionRejectionFloor(err) {
1147
+ const v = err?.minVersion;
1148
+ return typeof v === "string" && v.length > 0 ? v : void 0;
1149
+ }
1150
+ function versionRejectionSurface(err) {
1151
+ const v = err?.surface;
1152
+ return typeof v === "string" && v.length > 0 ? v : void 0;
1153
+ }
1093
1154
  function defaultScheduler(fn, ms) {
1094
1155
  const id = setTimeout(fn, ms);
1095
1156
  if (typeof id.unref === "function") {
@@ -2793,6 +2854,13 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2793
2854
  error: info.lastError
2794
2855
  });
2795
2856
  },
2857
+ onParked: (info) => {
2858
+ this.debug.emit(
2859
+ "sdk.parked",
2860
+ `[crossdeck] SDK parked \u2014 server no longer accepts this version's event format. Events held (paused, not lost); update @cross-deck/node${info.minVersion ? ` to >= ${info.minVersion}` : ""} and restart to resume.`,
2861
+ { ...info }
2862
+ );
2863
+ },
2796
2864
  onFirstFlushSuccess: () => {
2797
2865
  this.debug.emit("sdk.first_event_sent", "First batch landed.");
2798
2866
  }
@@ -4367,6 +4435,13 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
4367
4435
  description: "A field is present but the value failed validation.",
4368
4436
  resolution: "Read error.message for the field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
4369
4437
  retryable: false
4438
+ },
4439
+ {
4440
+ code: "sdk_version_unsupported",
4441
+ type: "version_error",
4442
+ description: "HTTP 426 \u2014 your installed SDK sends an event format the server no longer accepts. The data is good; only the wire dialect is too old. The SDK PARKS automatically: events are held in memory and deliver once you upgrade and restart.",
4443
+ resolution: "Update @cross-deck/node to at least the version in error.minVersion and restart \u2014 the held queue backfills. See https://cross-deck.com/docs/sdk-event-durability/.",
4444
+ retryable: false
4370
4445
  }
4371
4446
  ]);
4372
4447
  function isCrossdeckErrorCode(code) {