2.0.0: changelog and skill docs for slugs and environments
CI / check (push) Successful in 16s
CI / publish (push) Has been skipped

Claude-Session: https://claude.ai/code/session_01SMCvdwDmuxoaqGgvGBLk1V
This commit is contained in:
edmand46
2026-09-06 22:39:03 +03:00
parent a7401b534a
commit 7271874cac
9 changed files with 76 additions and 28 deletions
+33
View File
@@ -1,5 +1,38 @@
# Changelog # Changelog
## 2.0.0
Breaking change — major bump, required by the backend environments release.
Every project now has exactly two environments, `staging` and `prod`, and the
SDK key passed as `projectKey` decides which one the player belongs to. The
environment never appears in the client API: it is resolved at login and
carried inside the access and refresh tokens. Tokens issued before this
release have no environment claim and are rejected with 401, so the first call
after the backend upgrade refreshes, fails, clears the token store and emits
`'signed-out'` to every `onAuthStateChange` listener. Log the player in again
with `client.auth.loginWithDevice()` / `loginWithCustom()`.
Quests, scenarios and offers are addressed by their slug instead of their id,
because ids differ between staging and prod while slugs are stable. Renamed
accordingly: `Quest.id` is now `Quest.slug`, `client.quests.claim()` takes a
quest slug, `reportProgress()` resolves to the slugs of the completed quests
(`ReportQuestProgressResponse.completedQuestSlugs`),
`client.stores.purchase(storeSlug, offerSlug, options?)` takes an offer slug
and `OfferHandle` carries a `slug` alongside its `id`,
`QuestMetrics.purchaseOffer(offerSlug)` builds its metric from the offer slug,
and every scenario-scoped type exposes `scenarioSlug` instead of `scenarioId`
`PendingEffect`, `ScenarioCompletedEffect`, `ScenarioFailedEffect`, the battle
pass requests and `client.battlePass.getProgress(scenarioSlug, nodeId)`.
`offer.buy()` is unchanged; it now binds the slug for you. Leaderboards, items
and stores already used slugs and are untouched.
Also shipped here, previously committed but never published: the effects client
reconciles against every `GET /sdk/v1/scenarios/pending` response, so a run
that disappears server-side (finished elsewhere, expired after a promote) now
emits `onScenarioCompleted` instead of lingering; and `run_not_active` joins
`unknown_run` and `run_expired` as a terminal rejection that drops the run and
emits `onScenarioFailed`.
## 1.0.0 ## 1.0.0
- Scenario execution moved server-side. The SDK no longer walks a local DAG - Scenario execution moved server-side. The SDK no longer walks a local DAG
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@rudder/js-sdk", "name": "@rudder/js-sdk",
"version": "1.0.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@rudder/js-sdk", "name": "@rudder/js-sdk",
"version": "1.0.0", "version": "2.0.0",
"devDependencies": { "devDependencies": {
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@rudder/js-sdk", "name": "@rudder/js-sdk",
"version": "1.0.0", "version": "2.0.0",
"publishConfig": { "publishConfig": {
"registry": "https://hub.rudder.build/api/packages/rudder/npm/" "registry": "https://hub.rudder.build/api/packages/rudder/npm/"
}, },
+1 -1
View File
@@ -74,7 +74,7 @@ service, or standalone economy service in this SDK — wallets live on
| Player profile + wallets | `client.player` | observable `SyncedState<PlayerProfile>` | | Player profile + wallets | `client.player` | observable `SyncedState<PlayerProfile>` |
| Inventory (catalog-merged) | `client.inventory` | observable `SyncedState<InventoryItem[]>` | | Inventory (catalog-merged) | `client.inventory` | observable `SyncedState<InventoryItem[]>` |
| Catalog | `client.catalog` | observable `SyncedState<Map<string, CatalogItem>>` | | Catalog | `client.catalog` | observable `SyncedState<Map<string, CatalogItem>>` |
| Stores / purchases | `client.stores` + `client.stores.purchase(slug, offerId, opts?)` | observable `SyncedState<ShopHandle[]>` | | Stores / purchases | `client.stores` + `client.stores.purchase(slug, offerSlug, opts?)` | observable `SyncedState<ShopHandle[]>` |
| Remote config | `client.remoteConfig` + `.get(key, default?)` | observable, typed | | Remote config | `client.remoteConfig` + `.get(key, default?)` | observable, typed |
| Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations | | Player storage | `client.storage` + `.save(items)` / `.delete(type)` | observable + mutations |
| Project storage | `client.projectStorage` + `.save(items)` | observable + mutation | | Project storage | `client.projectStorage` + `.save(items)` | observable + mutation |
+13
View File
@@ -50,6 +50,19 @@ On success both tokens are saved to the client's `TokenStore`, the runtime
starts (domains warmed, scenario engine restored, `player_login` event fired), starts (domains warmed, scenario engine restored, `player_login` event fired),
and the state flips to `'signed-in'`. and the state flips to `'signed-in'`.
## Environments
A project has two environments, `staging` and `prod`. The SDK key you pass as
`RudderClientOptions.projectKey` belongs to one of them, so the environment is
resolved at login and carried inside the access and refresh tokens; nothing in
the client API takes an environment argument, and a player created in one
environment is invisible in the other. Content released only to `staging` is
empty for a `prod` key and vice versa.
Tokens issued before SDK 2.0.0 carry no environment claim and are rejected with
401. The transport's refresh then fails, clears the token store and emits
`'signed-out'` — log the player in again.
## Auth state ## Auth state
```ts ```ts
@@ -2,7 +2,7 @@
`BattlePassService` (source: `src/battlepass/BattlePassService.ts`). `BattlePassService` (source: `src/battlepass/BattlePassService.ts`).
Call-and-response access to battle pass endpoints. Battle pass state is tied to Call-and-response access to battle pass endpoints. Battle pass state is tied to
a **scenario battle pass node**, so calls carry `scenarioId` + `nodeId` (plus a **scenario battle pass node**, so calls carry `scenarioSlug` + `nodeId` (plus
`runId` for mutating calls). NOT observable — re-fetch progress explicitly `runId` for mutating calls). NOT observable — re-fetch progress explicitly
after a mutation. after a mutation.
@@ -14,7 +14,7 @@ you already know the scenario/node/run identifiers.
## Methods ## Methods
```ts ```ts
getProgress(scenarioId: string, nodeId: string): Promise<GetBattlePassProgressResponse> getProgress(scenarioSlug: string, nodeId: string): Promise<GetBattlePassProgressResponse>
addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse> addXp(request: AddBattlePassXpRequest): Promise<AddBattlePassXpResponse>
claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse> claimReward(request: ClaimBattlePassRewardRequest): Promise<ClaimBattlePassRewardResponse>
purchasePremium(request: PurchaseBattlePassPremiumRequest): Promise<PurchaseBattlePassPremiumResponse> purchasePremium(request: PurchaseBattlePassPremiumRequest): Promise<PurchaseBattlePassPremiumResponse>
@@ -27,7 +27,7 @@ interface AddBattlePassXpRequest {
amount?: number; amount?: number;
nodeId?: string; nodeId?: string;
runId?: string; runId?: string;
scenarioId?: string; scenarioSlug?: string;
source?: string; // configured XP source source?: string; // configured XP source
} }
interface AddBattlePassXpResponse { interface AddBattlePassXpResponse {
@@ -41,7 +41,7 @@ interface ClaimBattlePassRewardRequest {
level?: number; level?: number;
nodeId?: string; nodeId?: string;
runId?: string; runId?: string;
scenarioId?: string; scenarioSlug?: string;
track?: 'free' | 'premium'; track?: 'free' | 'premium';
} }
interface ClaimBattlePassRewardResponse { interface ClaimBattlePassRewardResponse {
@@ -62,7 +62,7 @@ interface PurchaseBattlePassPremiumRequest {
idempotencyKey?: string; idempotencyKey?: string;
nodeId?: string; nodeId?: string;
runId?: string; runId?: string;
scenarioId?: string; scenarioSlug?: string;
} }
interface PurchaseBattlePassPremiumResponse { interface PurchaseBattlePassPremiumResponse {
error?: string; error?: string;
@@ -80,5 +80,5 @@ interface PurchaseBattlePassPremiumResponse {
in the response and the typed error `code` (`RudderErrorCodes`). in the response and the typed error `code` (`RudderErrorCodes`).
- `purchasePremium` charges the player's wallet; idempotent; pass your own - `purchasePremium` charges the player's wallet; idempotent; pass your own
`idempotencyKey` for safe retries. `idempotencyKey` for safe retries.
- Prefer the `onBattlePass` effect session, which binds `scenarioId` / - Prefer the `onBattlePass` effect session, which binds `scenarioSlug` /
`nodeId` / `runId` and posts scenario callbacks for you. `nodeId` / `runId` and posts scenario callbacks for you.
+6 -6
View File
@@ -11,15 +11,15 @@ Distinct from scenario quest nodes, which advance through
```ts ```ts
list(): Promise<Quest[]> list(): Promise<Quest[]>
claim(questId: string): Promise<ClaimQuestResponse> claim(questSlug: string): Promise<ClaimQuestResponse>
reportProgress(metric: string, amount: number): Promise<string[]> // ids of quests completed by this report reportProgress(metric: string, amount: number): Promise<string[]> // slugs of quests completed by this report
``` ```
## Types ## Types
```ts ```ts
interface Quest { interface Quest {
id?: string; slug?: string; // stable across environments — what claim() takes
name?: string; name?: string;
objectives?: QuestObjectiveProgress[]; objectives?: QuestObjectiveProgress[];
rewards?: Reward[]; rewards?: Reward[];
@@ -47,12 +47,12 @@ interface ClaimQuestResponse {
```ts ```ts
const quests = await client.quests.list(); const quests = await client.quests.list();
for (const quest of quests) { for (const quest of quests) {
if (quest.status === 'completed' && quest.id) { if (quest.status === 'completed' && quest.slug) {
const res = await client.quests.claim(quest.id); const res = await client.quests.claim(quest.slug);
// res.success / res.alreadyClaimed / res.granted // res.success / res.alreadyClaimed / res.granted
} }
} }
const completedIds = await client.quests.reportProgress('kills', 1); const completedSlugs = await client.quests.reportProgress('kills', 1);
``` ```
- `claim` is idempotent server-side; check `success` / `alreadyClaimed` / - `claim` is idempotent server-side; check `success` / `alreadyClaimed` /
+4 -4
View File
@@ -122,7 +122,7 @@ counter response may carry the next `PendingEffect`.
} }
``` ```
These wrap `client.battlePass` with the run's `scenarioId`/`nodeId`/`runId` These wrap `client.battlePass` with the run's `scenarioSlug`/`nodeId`/`runId`
already bound — prefer them over calling the service manually. already bound — prefer them over calling the service manually.
### `BattlePassLevelEffect` — a `battlepass_level` node (single claimable tier) ### `BattlePassLevelEffect` — a `battlepass_level` node (single claimable tier)
@@ -134,9 +134,9 @@ already bound — prefer them over calling the service manually.
### Run lifecycle effects ### Run lifecycle effects
```ts ```ts
interface ScenarioCompletedEffect { readonly runId: string; readonly scenarioId: string } interface ScenarioCompletedEffect { readonly runId: string; readonly scenarioSlug: string }
interface ScenarioFailedEffect { interface ScenarioFailedEffect {
readonly runId: string; readonly scenarioId: string; readonly nodeId: string; readonly runId: string; readonly scenarioSlug: string; readonly nodeId: string;
readonly error: Error; readonly error: Error;
} }
``` ```
@@ -154,7 +154,7 @@ not delivered to the client.
## Reliability notes ## Reliability notes
- Completion methods POST `/sdk/v1/scenarios/callback` with - Completion methods POST `/sdk/v1/scenarios/callback` with
`{scenarioId, runId, nodeId, handle}` using the handles `output`, `{scenarioSlug, runId, nodeId, handle}` using the handles `output`,
`onPurchase`, `onDecline`, `onEnd`, `onClaim`, `onComplete`, `onLevelUp`, `onPurchase`, `onDecline`, `onEnd`, `onClaim`, `onComplete`, `onLevelUp`,
`onPremiumPurchase`. `onPremiumPurchase`.
- Transient callback failures (network error, 5xx) leave the effect active - Transient callback failures (network error, 5xx) leave the effect active
+10 -8
View File
@@ -13,7 +13,7 @@ client.stores.onChange(cb);
await client.stores.load() / .reload(); await client.stores.load() / .reload();
// Direct purchase (bypassing handles): // Direct purchase (bypassing handles):
await client.stores.purchase(storeSlug, offerId, options?); await client.stores.purchase(storeSlug, offerSlug, options?);
``` ```
## Handles ## Handles
@@ -28,7 +28,8 @@ class ShopHandle {
} }
class OfferHandle { class OfferHandle {
readonly id: string; readonly id: string; // authoring id, differs between staging and prod
readonly slug: string; // stable across environments — what purchases use
readonly name?: string; readonly name?: string;
readonly price?: OfferPrice; // { amount?: number; currency?: string } readonly price?: OfferPrice; // { amount?: number; currency?: string }
readonly contents: OfferContent[]; // { amount?: number; itemId?: string }[] readonly contents: OfferContent[]; // { amount?: number; itemId?: string }[]
@@ -48,15 +49,15 @@ interface PurchaseOfferResponse {
## Purchase semantics ## Purchase semantics
- `stores.purchase(storeSlug, offerId, options?)` and `offer.buy(options?)` are - `stores.purchase(storeSlug, offerSlug, options?)` and `offer.buy(options?)` are
the same executor; handles just bind the slug/id. the same executor; handles just bind the store slug and the offer slug.
- When `options.idempotencyKey` is omitted the SDK generates - When `options.idempotencyKey` is omitted the SDK generates
`crypto.randomUUID()` per call — safe retries require passing your own key. `crypto.randomUUID()` per call — safe retries require passing your own key.
- On success the executor invalidates `player`, `inventory`, and `stores`, so - On success the executor invalidates `player`, `inventory`, and `stores`, so
subscribers observe fresh wallet/inventory/store data immediately. subscribers observe fresh wallet/inventory/store data immediately.
- Check `response.success` / `response.error` — a failed purchase is a resolved - Check `response.success` / `response.error` — a failed purchase is a resolved
response, not necessarily a thrown error. response, not necessarily a thrown error.
- Purchase metrics (`purchase.offer:<offerId>`, `purchase.item:<itemId>`) are - Purchase metrics (`purchase.offer:<offerSlug>`, `purchase.item:<itemId>`) are
reported to quests automatically server-side — do not report them manually reported to quests automatically server-side — do not report them manually
(see `reference/quests.md`). (see `reference/quests.md`).
@@ -71,10 +72,11 @@ interface Store {
} }
interface Offer { interface Offer {
contents?: OfferContent[]; createdAt?: string; id?: string; contents?: OfferContent[]; createdAt?: string; id?: string;
maxPurchases?: number; name?: string; price?: OfferPrice; updatedAt?: string; maxPurchases?: number; name?: string; price?: OfferPrice; slug?: string;
updatedAt?: string;
} }
``` ```
`ShopHandle`/`OfferHandle` throw a plain `Error` if constructed from a store `ShopHandle`/`OfferHandle` throw a plain `Error` if constructed from a store
without `slug` / an offer without `id` — in practice the domain only builds without `slug` / an offer without `id` or `slug` — in practice the domain only
handles from server data that has both. builds handles from server data that has all of them.