@cross-deck/node 1.7.0 → 1.8.1

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,37 @@ 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.1] — 2026-06-21
10
+
11
+ **Docs.** The README now opens with the Buckets preview — "where your reads go,
12
+ before and after a fix" — so you can see what Crossdeck reports back before you
13
+ read a line of API. No code change; SDK behaviour is identical to 1.8.0.
14
+
15
+ ## [1.8.0] — 2026-06-21
16
+
17
+ **Self-defends against re-instantiation (Next.js / serverless).** Constructing
18
+ `new CrossdeckServer()` at module top-level is the documented pattern, but
19
+ frameworks like Next.js re-evaluate module scope (HMR, per-route isolation, chunk
20
+ splitting), so it could run many times in one process — each time firing another
21
+ boot heartbeat, starting another flush timer, stacking another set of process
22
+ listeners (`beforeExit` / `SIGTERM` / `uncaughtException` / `unhandledRejection`),
23
+ and re-wrapping global `fetch`. That produced a storm of duplicate phone-homes and
24
+ an `EventEmitter` max-listeners warning. The SDK now guards against it — minor,
25
+ backwards-compatible.
26
+
27
+ **Fixed:**
28
+
29
+ - **Singleton guard.** The constructor now returns the EXISTING instance for the
30
+ same credentials (secretKey + appId + baseUrl) instead of building a second one,
31
+ so re-evaluation never re-boots. Same defence Prisma / Firebase Admin ship for
32
+ the same reason. New `CrossdeckServer.clearSingletonCache()` static for tests /
33
+ bespoke hot-reload teardown.
34
+ - **Idempotent `fetch` wrap.** The error-capture fetch wrapper tags itself and
35
+ skips wrapping an already-wrapped `fetch`, so a double-install can't
36
+ double-capture every request.
37
+
7
38
  ## [1.7.0] — 2026-06-11
8
39
 
9
40
  **PARK on version-rejection — events are held, never dropped.** A third
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  The Crossdeck server SDK for Node.js — one install, three pillars: **errors**, **analytics**, **entitlements**.
4
4
 
5
+ ![Crossdeck Buckets — where your reads go, before and after a fix](assets/buckets-preview.svg)
6
+
7
+ **This is what you get back.** Crossdeck shows you where your database reads actually go — and what a fix did to them, hour by hour, before and after you shipped it. Ship a change, and the dashboard above tells you whether the curve bent. It's live in every Crossdeck project. [See it on your own data →](https://cross-deck.com)
8
+
5
9
  ```bash
6
10
  npm install @cross-deck/node
7
11
  ```
@@ -42,6 +46,8 @@ if (crossdeck.isEntitled({ userId: "user_847" }, "pro")) {
42
46
  }
43
47
  ```
44
48
 
49
+ 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.)_
50
+
45
51
  ## Three USPs, one SDK
46
52
 
47
53
  ### USP 1 — Errors
@@ -368,7 +374,7 @@ try {
368
374
  }
369
375
  ```
370
376
 
371
- Subclasses: `CrossdeckAuthenticationError`, `CrossdeckPermissionError`, `CrossdeckValidationError`, `CrossdeckRateLimitError`, `CrossdeckNetworkError`, `CrossdeckInternalError`, `CrossdeckConfigurationError`. All extend `CrossdeckError`. Constructed automatically by the SDK — you never need to instantiate them yourself.
377
+ 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
378
 
373
379
  `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
380
 
@@ -397,6 +403,12 @@ new CrossdeckServer({
397
403
 
398
404
  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
405
 
406
+ ### Outdated-version PARK (v1.7.0)
407
+
408
+ 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.
409
+
410
+ **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/).
411
+
400
412
  **v1.4.0 — `syncPurchases` deterministic key.** The Idempotency-Key
401
413
  on `syncPurchases` is derived from the request body (UUID-shaped
402
414
  SHA-256 of `crossdeck:purchases/sync:<rail>:<jws|token>`). Two retries
@@ -584,8 +596,8 @@ CrossdeckContracts.byId("idempotency-key-deterministic");
584
596
  CrossdeckContracts.byPillar("revenue");
585
597
  CrossdeckContracts.withStatus("proposed");
586
598
  CrossdeckContracts.findByTestName("rail namespacing prevents cross-rail collisions");
587
- CrossdeckContracts.sdkVersion; // "1.5.0"
588
- CrossdeckContracts.bundledIn; // "@cross-deck/node@1.5.0"
599
+ CrossdeckContracts.sdkVersion; // "1.7.0"
600
+ CrossdeckContracts.bundledIn; // "@cross-deck/node@1.7.0"
589
601
  ```
590
602
 
591
603
  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-DYawt4eT.mjs';
1
+ import { w as CrossdeckServer } from '../crossdeck-server-DrLCOoeB.mjs';
2
2
  import 'node:events';
3
3
 
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { w as CrossdeckServer } from '../crossdeck-server-DYawt4eT.js';
1
+ import { w as CrossdeckServer } from '../crossdeck-server-DrLCOoeB.js';
2
2
  import 'node:events';
3
3
 
4
4
  /**
@@ -183,8 +183,8 @@ declare const CrossdeckContracts: {
183
183
  readonly byId: (id: string) => Contract | undefined;
184
184
  readonly byPillar: (pillar: ContractPillar) => readonly Contract[];
185
185
  readonly withStatus: (status: ContractStatus) => readonly Contract[];
186
- readonly sdkVersion: "1.6.0";
187
- readonly bundledIn: "@cross-deck/node@1.6.0";
186
+ readonly sdkVersion: "1.8.1";
187
+ readonly bundledIn: "@cross-deck/node@1.8.1";
188
188
  /**
189
189
  * Resolve a failing test back to the contract it exercises.
190
190
  * Used by test-framework hooks to find the contract id of a
@@ -1385,6 +1385,14 @@ declare class CrossdeckServer extends EventEmitter {
1385
1385
  */
1386
1386
  private didEmitShutdown;
1387
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;
1388
1396
  /**
1389
1397
  * Emit the honest "no cold-start durability" warning when the runtime
1390
1398
  * is serverless AND no `entitlementStore` is wired. Local-only debug
@@ -183,8 +183,8 @@ declare const CrossdeckContracts: {
183
183
  readonly byId: (id: string) => Contract | undefined;
184
184
  readonly byPillar: (pillar: ContractPillar) => readonly Contract[];
185
185
  readonly withStatus: (status: ContractStatus) => readonly Contract[];
186
- readonly sdkVersion: "1.6.0";
187
- readonly bundledIn: "@cross-deck/node@1.6.0";
186
+ readonly sdkVersion: "1.8.1";
187
+ readonly bundledIn: "@cross-deck/node@1.8.1";
188
188
  /**
189
189
  * Resolve a failing test back to the contract it exercises.
190
190
  * Used by test-framework hooks to find the contract id of a
@@ -1385,6 +1385,14 @@ declare class CrossdeckServer extends EventEmitter {
1385
1385
  */
1386
1386
  private didEmitShutdown;
1387
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;
1388
1396
  /**
1389
1397
  * Emit the honest "no cold-start durability" warning when the runtime
1390
1398
  * is serverless AND no `entitlementStore` is wired. Local-only debug
package/dist/index.cjs CHANGED
@@ -387,7 +387,7 @@ function byteLength(s) {
387
387
  var https = __toESM(require("https"));
388
388
 
389
389
  // src/_version.ts
390
- var SDK_VERSION = "1.7.0";
390
+ var SDK_VERSION = "1.8.1";
391
391
  var SDK_NAME = "@cross-deck/node";
392
392
 
393
393
  // src/_diagnostic-telemetry.ts
@@ -1433,6 +1433,7 @@ var ErrorTracker = class {
1433
1433
  installFetchWrap() {
1434
1434
  const origFetch = globalThis.fetch;
1435
1435
  if (typeof origFetch !== "function") return;
1436
+ if (origFetch.__crossdeckWrapped__) return;
1436
1437
  const tracker = this;
1437
1438
  const wrapped = async (...args) => {
1438
1439
  const input = args[0];
@@ -1473,6 +1474,7 @@ var ErrorTracker = class {
1473
1474
  throw err;
1474
1475
  }
1475
1476
  };
1477
+ wrapped.__crossdeckWrapped__ = true;
1476
1478
  globalThis.fetch = wrapped;
1477
1479
  this.cleanups.push(() => {
1478
1480
  if (globalThis.fetch === wrapped) globalThis.fetch = origFetch;
@@ -2678,6 +2680,9 @@ function safeJson(obj) {
2678
2680
 
2679
2681
  // src/crossdeck-server.ts
2680
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.
2681
2686
  http;
2682
2687
  sdkVersion;
2683
2688
  baseUrl;
@@ -2773,6 +2778,13 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2773
2778
  this.appId = options.appId;
2774
2779
  this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
2775
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
+ }
2776
2788
  this.secretKeyPrefix = maskSecretKey(options.secretKey);
2777
2789
  this.scrubPii = options.scrubPii !== false;
2778
2790
  this.http = new HttpClient({
@@ -2914,6 +2926,17 @@ var CrossdeckServer = class extends import_node_events.EventEmitter {
2914
2926
  this.emitBootTelemetryEvent();
2915
2927
  });
2916
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();
2917
2940
  }
2918
2941
  /**
2919
2942
  * Emit the honest "no cold-start durability" warning when the runtime
@@ -4573,8 +4596,8 @@ function normaliseSecrets(input) {
4573
4596
  }
4574
4597
 
4575
4598
  // src/_contracts-bundled.ts
4576
- var BUNDLED_IN = "@cross-deck/node@1.6.0";
4577
- var SDK_VERSION2 = "1.6.0";
4599
+ var BUNDLED_IN = "@cross-deck/node@1.8.1";
4600
+ var SDK_VERSION2 = "1.8.1";
4578
4601
  var BUNDLED_CONTRACTS = Object.freeze([
4579
4602
  {
4580
4603
  "id": "contract-failed-payload-schema-lock",
@@ -4696,7 +4719,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4696
4719
  "legal/security/index.html#diagnostic",
4697
4720
  "legal/sdk-data/index.html#b-diagnostic"
4698
4721
  ],
4699
- "bundledIn": "@cross-deck/node@1.6.0"
4722
+ "bundledIn": "@cross-deck/node@1.8.1"
4700
4723
  },
4701
4724
  {
4702
4725
  "id": "documentation-honesty",
@@ -4728,7 +4751,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4728
4751
  ],
4729
4752
  "registeredAt": "2026-05-26",
4730
4753
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.1",
4731
- "bundledIn": "@cross-deck/node@1.6.0"
4754
+ "bundledIn": "@cross-deck/node@1.8.1"
4732
4755
  },
4733
4756
  {
4734
4757
  "id": "error-envelope-shape",
@@ -4767,7 +4790,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4767
4790
  ],
4768
4791
  "registeredAt": "2026-05-26",
4769
4792
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
4770
- "bundledIn": "@cross-deck/node@1.6.0"
4793
+ "bundledIn": "@cross-deck/node@1.8.1"
4771
4794
  },
4772
4795
  {
4773
4796
  "id": "flush-interval-parity",
@@ -4812,7 +4835,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4812
4835
  ],
4813
4836
  "registeredAt": "2026-05-26",
4814
4837
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
4815
- "bundledIn": "@cross-deck/node@1.6.0"
4838
+ "bundledIn": "@cross-deck/node@1.8.1"
4816
4839
  },
4817
4840
  {
4818
4841
  "id": "idempotency-key-deterministic",
@@ -4917,7 +4940,63 @@ var BUNDLED_CONTRACTS = Object.freeze([
4917
4940
  ],
4918
4941
  "registeredAt": "2026-05-26",
4919
4942
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
4920
- "bundledIn": "@cross-deck/node@1.6.0"
4943
+ "bundledIn": "@cross-deck/node@1.8.1"
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.1"
4921
5000
  },
4922
5001
  {
4923
5002
  "id": "node-pii-scrubber",
@@ -4956,7 +5035,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4956
5035
  ],
4957
5036
  "registeredAt": "2026-05-26",
4958
5037
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.1",
4959
- "bundledIn": "@cross-deck/node@1.6.0"
5038
+ "bundledIn": "@cross-deck/node@1.8.1"
4960
5039
  },
4961
5040
  {
4962
5041
  "id": "node-shutdown-awaits-flush",
@@ -4989,7 +5068,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
4989
5068
  ],
4990
5069
  "registeredAt": "2026-05-26",
4991
5070
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.4",
4992
- "bundledIn": "@cross-deck/node@1.6.0"
5071
+ "bundledIn": "@cross-deck/node@1.8.1"
4993
5072
  },
4994
5073
  {
4995
5074
  "id": "sdk-error-codes-catalogue",
@@ -5034,7 +5113,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
5034
5113
  ],
5035
5114
  "registeredAt": "2026-05-26",
5036
5115
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
5037
- "bundledIn": "@cross-deck/node@1.6.0"
5116
+ "bundledIn": "@cross-deck/node@1.8.1"
5038
5117
  },
5039
5118
  {
5040
5119
  "id": "sync-purchases-funnel-parity",
@@ -5067,7 +5146,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
5067
5146
  ],
5068
5147
  "registeredAt": "2026-05-26",
5069
5148
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
5070
- "bundledIn": "@cross-deck/node@1.6.0"
5149
+ "bundledIn": "@cross-deck/node@1.8.1"
5071
5150
  },
5072
5151
  {
5073
5152
  "id": "verifier-timestamp-mandatory",
@@ -5121,7 +5200,7 @@ var BUNDLED_CONTRACTS = Object.freeze([
5121
5200
  ],
5122
5201
  "registeredAt": "2026-05-26",
5123
5202
  "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 7.2",
5124
- "bundledIn": "@cross-deck/node@1.6.0"
5203
+ "bundledIn": "@cross-deck/node@1.8.1"
5125
5204
  }
5126
5205
  ]);
5127
5206