@cross-deck/node 1.6.0 → 1.8.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,66 @@ 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
+ ## [Unreleased]
8
+
9
+ ## [1.8.0] — 2026-06-21
10
+
11
+ **Self-defends against re-instantiation (Next.js / serverless).** Constructing
12
+ `new CrossdeckServer()` at module top-level is the documented pattern, but
13
+ frameworks like Next.js re-evaluate module scope (HMR, per-route isolation, chunk
14
+ splitting), so it could run many times in one process — each time firing another
15
+ boot heartbeat, starting another flush timer, stacking another set of process
16
+ listeners (`beforeExit` / `SIGTERM` / `uncaughtException` / `unhandledRejection`),
17
+ and re-wrapping global `fetch`. That produced a storm of duplicate phone-homes and
18
+ an `EventEmitter` max-listeners warning. The SDK now guards against it — minor,
19
+ backwards-compatible.
20
+
21
+ **Fixed:**
22
+
23
+ - **Singleton guard.** The constructor now returns the EXISTING instance for the
24
+ same credentials (secretKey + appId + baseUrl) instead of building a second one,
25
+ so re-evaluation never re-boots. Same defence Prisma / Firebase Admin ship for
26
+ the same reason. New `CrossdeckServer.clearSingletonCache()` static for tests /
27
+ bespoke hot-reload teardown.
28
+ - **Idempotent `fetch` wrap.** The error-capture fetch wrapper tags itself and
29
+ skips wrapping an already-wrapped `fetch`, so a double-install can't
30
+ double-capture every request.
31
+
32
+ ## [1.7.0] — 2026-06-11
33
+
34
+ **PARK on version-rejection — events are held, never dropped.** A third
35
+ event-queue outcome for the day the server stops accepting an outdated event
36
+ format. Purely additive; no public API change.
37
+
38
+ **Added:**
39
+
40
+ - **PARK (HTTP `426` / `sdk_version_unsupported`).** A version-rejection is now
41
+ recognised as its own outcome — distinct from retry (transient) and drop
42
+ (invalid): the data is good, only the wire dialect is stale. The queue
43
+ **holds** the events (folded to the buffer front, FIFO-capped at 1000),
44
+ **hushes** (stops flushing a known-too-old payload), **signals** once (one
45
+ `console.warn` + a typed `sdk.parked` debug event), and delivers on restart
46
+ after you upgrade. Node's queue is in-memory, so a process restart *before*
47
+ upgrade clears the held events — an opt-in disk-backed queue is on the
48
+ roadmap; the messaging says exactly this, never more.
49
+ - **`sdk_version_unsupported`** added to the error-codes catalogue with
50
+ remediation, and `version_error` to `CrossdeckErrorType`. `CrossdeckError`
51
+ carries `minVersion` / `surface` from the 426 body. New `onParked` callback.
52
+
53
+ **Fixed (no public API change):**
54
+
55
+ - The empty-input contract is now codified cross-SDK as
56
+ `invalid-input-rejected-natively`: `track("")` / `aliasIdentity` with a
57
+ missing `userId` reject at the call site by throwing a typed `CrossdeckError`
58
+ (`missing_event_name` / `missing_user_id`) and never reach the wire — the
59
+ Node/JS idiom of the invariant *"invalid input never crashes the app."* No
60
+ behaviour change; the guarantee is now documented and bundled.
61
+ - Standalone-build fix: the `contract-failed` schema-lock test now reads the
62
+ bundled contract (`_contracts-bundled.ts`) instead of the monorepo
63
+ `contracts/` path, so the published-mirror release build no longer fails.
64
+
65
+ See https://cross-deck.com/docs/sdk-event-durability/ for the durability contract.
66
+
7
67
  ## [1.6.0] — 2026-06-10
8
68
 
9
69
  Event Envelope v1 conformance — server-enforced contract (spec
package/README.md CHANGED
@@ -42,6 +42,8 @@ if (crossdeck.isEntitled({ userId: "user_847" }, "pro")) {
42
42
  }
43
43
  ```
44
44
 
45
+ Construct the client **once** at module scope and import it where you need it. This is safe even under frameworks that re-evaluate modules (Next.js HMR, per-route isolation, React Server Components): for a given secret key the SDK returns the same instance every time, so you never get a duplicate client. _(Single-instance guard added in 1.8.0.)_
46
+
45
47
  ## Three USPs, one SDK
46
48
 
47
49
  ### USP 1 — Errors
@@ -368,7 +370,7 @@ try {
368
370
  }
369
371
  ```
370
372
 
371
- Subclasses: `CrossdeckAuthenticationError`, `CrossdeckPermissionError`, `CrossdeckValidationError`, `CrossdeckRateLimitError`, `CrossdeckNetworkError`, `CrossdeckInternalError`, `CrossdeckConfigurationError`. All extend `CrossdeckError`. Constructed automatically by the SDK — you never need to instantiate them yourself.
373
+ Subclasses: `CrossdeckAuthenticationError`, `CrossdeckPermissionError`, `CrossdeckValidationError`, `CrossdeckRateLimitError`, `CrossdeckNetworkError`, `CrossdeckInternalError`, `CrossdeckConfigurationError`. All extend `CrossdeckError`. The `version_error` type (code `sdk_version_unsupported`, HTTP 426) carries `minVersion`/`surface` and routes to PARK — see "Outdated-version PARK" above. Constructed automatically by the SDK — you never need to instantiate them yourself.
372
374
 
373
375
  `CrossdeckErrorCode` is the literal union of every documented code in `CROSSDECK_ERROR_CODES`. Use `isCrossdeckErrorCode` to narrow `string` to the union for type-safe comparisons (catches misspelled codes at compile time).
374
376
 
@@ -397,6 +399,12 @@ new CrossdeckServer({
397
399
 
398
400
  POST methods (`track`/`ingest`/`syncPurchases`/`grantEntitlement`/`revokeEntitlement`) DO NOT auto-retry at the HTTP layer. Retries happen via the event queue with per-batch `Idempotency-Key` reuse — the server can dedupe replays.
399
401
 
402
+ ### Outdated-version PARK (v1.7.0)
403
+
404
+ If the server ever stops accepting this SDK version's event format, the rejection is machine-distinguishable — HTTP `426` with code `sdk_version_unsupported` — and the queue treats it as its own outcome, distinct from retry (transient) and drop (invalid): the events are **parked**. The queue holds them (FIFO-capped at 1,000), stops flushing a known-too-old payload, warns once on the console naming the exact version to update to, and fires the `onParked` callback + a typed `sdk.parked` debug event.
405
+
406
+ **Honest bound:** the Node queue is in-memory, so a process restart *before* you upgrade clears the held events — an opt-in disk-backed queue is on the roadmap. After you deploy the upgraded SDK, held events deliver on the next flush. Web/RN/Swift hold theirs durably across restarts. Full story: [the durability contract](https://cross-deck.com/docs/sdk-event-durability/).
407
+
400
408
  **v1.4.0 — `syncPurchases` deterministic key.** The Idempotency-Key
401
409
  on `syncPurchases` is derived from the request body (UUID-shaped
402
410
  SHA-256 of `crossdeck:purchases/sync:<rail>:<jws|token>`). Two retries
@@ -584,8 +592,8 @@ CrossdeckContracts.byId("idempotency-key-deterministic");
584
592
  CrossdeckContracts.byPillar("revenue");
585
593
  CrossdeckContracts.withStatus("proposed");
586
594
  CrossdeckContracts.findByTestName("rail namespacing prevents cross-rail collisions");
587
- CrossdeckContracts.sdkVersion; // "1.5.0"
588
- CrossdeckContracts.bundledIn; // "@cross-deck/node@1.5.0"
595
+ CrossdeckContracts.sdkVersion; // "1.7.0"
596
+ CrossdeckContracts.bundledIn; // "@cross-deck/node@1.7.0"
589
597
  ```
590
598
 
591
599
  The `Contract` type is exported alongside; the binary-stability promise is documented in [`contracts/README.md`](https://github.com/VistaApps-za/crossdeck/blob/main/contracts/README.md).
@@ -1,4 +1,4 @@
1
- import { w as CrossdeckServer } from '../crossdeck-server-C1Ue0rv4.mjs';
1
+ import { w as CrossdeckServer } from '../crossdeck-server-D9RvKxgA.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-D9RvKxgA.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,
@@ -173,8 +183,8 @@ declare const CrossdeckContracts: {
173
183
  readonly byId: (id: string) => Contract | undefined;
174
184
  readonly byPillar: (pillar: ContractPillar) => readonly Contract[];
175
185
  readonly withStatus: (status: ContractStatus) => readonly Contract[];
176
- readonly sdkVersion: "1.6.0";
177
- readonly bundledIn: "@cross-deck/node@1.6.0";
186
+ readonly sdkVersion: "1.8.0";
187
+ readonly bundledIn: "@cross-deck/node@1.8.0";
178
188
  /**
179
189
  * Resolve a failing test back to the contract it exercises.
180
190
  * Used by test-framework hooks to find the contract id of a
@@ -1375,6 +1385,14 @@ declare class CrossdeckServer extends EventEmitter {
1375
1385
  */
1376
1386
  private didEmitShutdown;
1377
1387
  constructor(options: CrossdeckServerOptions);
1388
+ /**
1389
+ * Clear the process-wide singleton cache. The SDK hands back the SAME instance
1390
+ * for the same credentials (the Next.js / serverless re-instantiation guard in
1391
+ * the constructor); this resets that so the next `new CrossdeckServer()` builds a
1392
+ * fresh instance. For TESTS (per-test isolation) and bespoke hot-reload teardown
1393
+ * only — production code never needs it.
1394
+ */
1395
+ static clearSingletonCache(): void;
1378
1396
  /**
1379
1397
  * Emit the honest "no cold-start durability" warning when the runtime
1380
1398
  * is serverless AND no `entitlementStore` is wired. Local-only debug
@@ -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,
@@ -173,8 +183,8 @@ declare const CrossdeckContracts: {
173
183
  readonly byId: (id: string) => Contract | undefined;
174
184
  readonly byPillar: (pillar: ContractPillar) => readonly Contract[];
175
185
  readonly withStatus: (status: ContractStatus) => readonly Contract[];
176
- readonly sdkVersion: "1.6.0";
177
- readonly bundledIn: "@cross-deck/node@1.6.0";
186
+ readonly sdkVersion: "1.8.0";
187
+ readonly bundledIn: "@cross-deck/node@1.8.0";
178
188
  /**
179
189
  * Resolve a failing test back to the contract it exercises.
180
190
  * Used by test-framework hooks to find the contract id of a
@@ -1375,6 +1385,14 @@ declare class CrossdeckServer extends EventEmitter {
1375
1385
  */
1376
1386
  private didEmitShutdown;
1377
1387
  constructor(options: CrossdeckServerOptions);
1388
+ /**
1389
+ * Clear the process-wide singleton cache. The SDK hands back the SAME instance
1390
+ * for the same credentials (the Next.js / serverless re-instantiation guard in
1391
+ * the constructor); this resets that so the next `new CrossdeckServer()` builds a
1392
+ * fresh instance. For TESTS (per-test isolation) and bespoke hot-reload teardown
1393
+ * only — production code never needs it.
1394
+ */
1395
+ static clearSingletonCache(): void;
1378
1396
  /**
1379
1397
  * Emit the honest "no cold-start durability" warning when the runtime
1380
1398
  * is serverless AND no `entitlementStore` is wired. Local-only debug
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.8.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") {
@@ -1372,6 +1433,7 @@ var ErrorTracker = class {
1372
1433
  installFetchWrap() {
1373
1434
  const origFetch = globalThis.fetch;
1374
1435
  if (typeof origFetch !== "function") return;
1436
+ if (origFetch.__crossdeckWrapped__) return;
1375
1437
  const tracker = this;
1376
1438
  const wrapped = async (...args) => {
1377
1439
  const input = args[0];
@@ -1412,6 +1474,7 @@ var ErrorTracker = class {
1412
1474
  throw err;
1413
1475
  }
1414
1476
  };
1477
+ wrapped.__crossdeckWrapped__ = true;
1415
1478
  globalThis.fetch = wrapped;
1416
1479
  this.cleanups.push(() => {
1417
1480
  if (globalThis.fetch === wrapped) globalThis.fetch = origFetch;
@@ -2617,6 +2680,9 @@ function safeJson(obj) {
2617
2680
 
2618
2681
  // src/crossdeck-server.ts
2619
2682
  var CrossdeckServer = class extends import_node_events.EventEmitter {
2683
+ // `!` (definite assignment): these are assigned on the real construction path,
2684
+ // but the singleton guard in the constructor can `return` an existing instance
2685
+ // before reaching them — that early return is the only path that skips them.
2620
2686
  http;
2621
2687
  sdkVersion;
2622
2688
  baseUrl;
@@ -2712,6 +2778,13 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2712
2778
  this.appId = options.appId;
2713
2779
  this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
2714
2780
  this.env = inferEnvFromKey(options.secretKey);
2781
+ const _store = globalThis;
2782
+ const _singletonKey = `${options.secretKey}|${this.appId ?? ""}|${this.baseUrl}`;
2783
+ _store.__crossdeckServers__ ??= /* @__PURE__ */ new Map();
2784
+ const _existing = _store.__crossdeckServers__.get(_singletonKey);
2785
+ if (_existing) {
2786
+ return _existing;
2787
+ }
2715
2788
  this.secretKeyPrefix = maskSecretKey(options.secretKey);
2716
2789
  this.scrubPii = options.scrubPii !== false;
2717
2790
  this.http = new HttpClient({
@@ -2793,6 +2866,13 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2793
2866
  error: info.lastError
2794
2867
  });
2795
2868
  },
2869
+ onParked: (info) => {
2870
+ this.debug.emit(
2871
+ "sdk.parked",
2872
+ `[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.`,
2873
+ { ...info }
2874
+ );
2875
+ },
2796
2876
  onFirstFlushSuccess: () => {
2797
2877
  this.debug.emit("sdk.first_event_sent", "First batch landed.");
2798
2878
  }
@@ -2846,6 +2926,17 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2846
2926
  this.emitBootTelemetryEvent();
2847
2927
  });
2848
2928
  }
2929
+ _store.__crossdeckServers__.set(_singletonKey, this);
2930
+ }
2931
+ /**
2932
+ * Clear the process-wide singleton cache. The SDK hands back the SAME instance
2933
+ * for the same credentials (the Next.js / serverless re-instantiation guard in
2934
+ * the constructor); this resets that so the next `new CrossdeckServer()` builds a
2935
+ * fresh instance. For TESTS (per-test isolation) and bespoke hot-reload teardown
2936
+ * only — production code never needs it.
2937
+ */
2938
+ static clearSingletonCache() {
2939
+ globalThis.__crossdeckServers__?.clear();
2849
2940
  }
2850
2941
  /**
2851
2942
  * Emit the honest "no cold-start durability" warning when the runtime
@@ -4367,6 +4458,13 @@ var _CROSSDECK_ERROR_CODES = Object.freeze([
4367
4458
  description: "A field is present but the value failed validation.",
4368
4459
  resolution: "Read error.message for the field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
4369
4460
  retryable: false
4461
+ },
4462
+ {
4463
+ code: "sdk_version_unsupported",
4464
+ type: "version_error",
4465
+ 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.",
4466
+ 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/.",
4467
+ retryable: false
4370
4468
  }
4371
4469
  ]);
4372
4470
  function isCrossdeckErrorCode(code) {
@@ -4498,8 +4596,8 @@ function normaliseSecrets(input) {
4498
4596
  }
4499
4597
 
4500
4598
  // src/_contracts-bundled.ts
4501
- var BUNDLED_IN = "@cross-deck/node@1.6.0";
4502
- var SDK_VERSION2 = "1.6.0";
4599
+ var BUNDLED_IN = "@cross-deck/node@1.8.0";
4600
+ var SDK_VERSION2 = "1.8.0";
4503
4601
  var BUNDLED_CONTRACTS = Object.freeze([
4504
4602
  {
4505
4603
  "id": "contract-failed-payload-schema-lock",
@@ -4621,7 +4719,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4621
4719
  "legal/security/index.html#diagnostic",
4622
4720
  "legal/sdk-data/index.html#b-diagnostic"
4623
4721
  ],
4624
- "bundledIn": "@cross-deck/node@1.6.0"
4722
+ "bundledIn": "@cross-deck/node@1.8.0"
4625
4723
  },
4626
4724
  {
4627
4725
  "id": "documentation-honesty",
@@ -4653,7 +4751,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4653
4751
  ],
4654
4752
  "registeredAt": "2026-05-26",
4655
4753
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.1",
4656
- "bundledIn": "@cross-deck/node@1.6.0"
4754
+ "bundledIn": "@cross-deck/node@1.8.0"
4657
4755
  },
4658
4756
  {
4659
4757
  "id": "error-envelope-shape",
@@ -4692,7 +4790,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4692
4790
  ],
4693
4791
  "registeredAt": "2026-05-26",
4694
4792
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
4695
- "bundledIn": "@cross-deck/node@1.6.0"
4793
+ "bundledIn": "@cross-deck/node@1.8.0"
4696
4794
  },
4697
4795
  {
4698
4796
  "id": "flush-interval-parity",
@@ -4737,7 +4835,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4737
4835
  ],
4738
4836
  "registeredAt": "2026-05-26",
4739
4837
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
4740
- "bundledIn": "@cross-deck/node@1.6.0"
4838
+ "bundledIn": "@cross-deck/node@1.8.0"
4741
4839
  },
4742
4840
  {
4743
4841
  "id": "idempotency-key-deterministic",
@@ -4842,7 +4940,63 @@ var BUNDLED_CONTRACTS = Object.freeze([
4842
4940
  ],
4843
4941
  "registeredAt": "2026-05-26",
4844
4942
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
4845
- "bundledIn": "@cross-deck/node@1.6.0"
4943
+ "bundledIn": "@cross-deck/node@1.8.0"
4944
+ },
4945
+ {
4946
+ "id": "invalid-input-rejected-natively",
4947
+ "pillar": "errors",
4948
+ "status": "enforced",
4949
+ "claim": "No public SDK API ever crashes the host app, and invalid input never reaches the wire. Invalid input (empty event name, empty userId, out-of-range config such as a non-positive breadcrumb capacity, NaN/Infinity/oversize/cyclic property values) is rejected at the call site WITHOUT a fatal trap. The signalling IDIOM is per-language and intentionally NOT uniform: Web, Node, and React Native THROW a typed CrossdeckError synchronously (code missing_event_name / missing_user_id / invalid_request_error) \u2014 a normal, catchable JavaScript convention where an uncaught throw logs and the app continues; Swift DROPS with a debug-log signal (track_dropped / identify_dropped) to match its non-throwing fire-and-forget surface, and exposes the throwing equivalent only via identifyAndWait(userId:). What is UNIFORM is the invariant, not the mechanism: every SDK rejects the same inputs, no public fire-and-forget API contains a fatalError / assertionFailure / precondition reachable from customer input, and no rejected input is enqueued or transmitted. Swift additionally proves this in BOTH debug and release configuration, because precondition fires under -O while assertionFailure does not. The bug class this contract closes was never the per-language difference \u2014 it was the UNDECLARED difference: each SDK's public API documentation must state its own semantics explicitly (TS docs: 'throws on empty name'; Swift docs: 'drops and logs').",
4950
+ "appliesTo": [
4951
+ "web",
4952
+ "node",
4953
+ "react-native",
4954
+ "swift"
4955
+ ],
4956
+ "codeRef": [
4957
+ "sdks/web/src/crossdeck.ts",
4958
+ "sdks/node/src/crossdeck-server.ts",
4959
+ "sdks/react-native/src/crossdeck.ts",
4960
+ "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
4961
+ "sdks/swift/Sources/Crossdeck/Breadcrumbs.swift"
4962
+ ],
4963
+ "testRef": [
4964
+ {
4965
+ "file": "sdks/web/tests/crossdeck.test.ts",
4966
+ "name": "track with empty name throws synchronously"
4967
+ },
4968
+ {
4969
+ "file": "sdks/web/tests/crossdeck.test.ts",
4970
+ "name": "rejects empty userId"
4971
+ },
4972
+ {
4973
+ "file": "sdks/node/tests/crossdeck-server.test.ts",
4974
+ "name": "track() throws CrossdeckError with code 'missing_event_name' when event name is empty"
4975
+ },
4976
+ {
4977
+ "file": "sdks/react-native/tests/crossdeck.test.ts",
4978
+ "name": "track('') throws CrossdeckError(missing_event_name) synchronously"
4979
+ },
4980
+ {
4981
+ "file": "sdks/react-native/tests/crossdeck.test.ts",
4982
+ "name": "identify('') rejects with CrossdeckError(missing_user_id)"
4983
+ },
4984
+ {
4985
+ "file": "sdks/swift/Tests/CrossdeckTests/CrossdeckPublicAPITests.swift",
4986
+ "name": "test_track_dropsEmptyName"
4987
+ },
4988
+ {
4989
+ "file": "sdks/swift/Tests/CrossdeckTests/CrossdeckPublicAPITests.swift",
4990
+ "name": "test_identifyAndWait_rejectsEmptyId"
4991
+ },
4992
+ {
4993
+ "file": "sdks/swift/Tests/CrossdeckTests/PublicAPIInputSafetyTests.swift",
4994
+ "name": "test_start_withZeroBreadcrumbCapacity_doesNotTrap"
4995
+ }
4996
+ ],
4997
+ "registeredAt": "2026-06-11",
4998
+ "firstRegisteredIn": "swift trap-on-input class fix \u2014 first machine-tested Swift release",
4999
+ "bundledIn": "@cross-deck/node@1.8.0"
4846
5000
  },
4847
5001
  {
4848
5002
  "id": "node-pii-scrubber",
@@ -4881,7 +5035,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4881
5035
  ],
4882
5036
  "registeredAt": "2026-05-26",
4883
5037
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.1",
4884
- "bundledIn": "@cross-deck/node@1.6.0"
5038
+ "bundledIn": "@cross-deck/node@1.8.0"
4885
5039
  },
4886
5040
  {
4887
5041
  "id": "node-shutdown-awaits-flush",
@@ -4914,7 +5068,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4914
5068
  ],
4915
5069
  "registeredAt": "2026-05-26",
4916
5070
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.4",
4917
- "bundledIn": "@cross-deck/node@1.6.0"
5071
+ "bundledIn": "@cross-deck/node@1.8.0"
4918
5072
  },
4919
5073
  {
4920
5074
  "id": "sdk-error-codes-catalogue",
@@ -4959,7 +5113,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4959
5113
  ],
4960
5114
  "registeredAt": "2026-05-26",
4961
5115
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
4962
- "bundledIn": "@cross-deck/node@1.6.0"
5116
+ "bundledIn": "@cross-deck/node@1.8.0"
4963
5117
  },
4964
5118
  {
4965
5119
  "id": "sync-purchases-funnel-parity",
@@ -4992,7 +5146,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4992
5146
  ],
4993
5147
  "registeredAt": "2026-05-26",
4994
5148
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
4995
- "bundledIn": "@cross-deck/node@1.6.0"
5149
+ "bundledIn": "@cross-deck/node@1.8.0"
4996
5150
  },
4997
5151
  {
4998
5152
  "id": "verifier-timestamp-mandatory",
@@ -5046,7 +5200,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
5046
5200
  ],
5047
5201
  "registeredAt": "2026-05-26",
5048
5202
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.2",
5049
- "bundledIn": "@cross-deck/node@1.6.0"
5203
+ "bundledIn": "@cross-deck/node@1.8.0"
5050
5204
  }
5051
5205
  ]);
5052
5206