chore: remove effects leftovers, dead SyncedState.set, add modules/resources skill refs

This commit is contained in:
edmand46
2026-09-17 14:15:37 +03:00
parent 4ab406c5cd
commit 34c53bbb68
14 changed files with 89 additions and 208 deletions
+4
View File
@@ -2,6 +2,10 @@
## Unreleased ## Unreleased
- Removed `client.effects`, the `Effects` / `EffectUnsubscribe` /
`NotificationEffect` / `WaitEffect` / `ScenarioCompletedEffect` /
`ScenarioFailedEffect` types, and the `onEffectError` client option. Nothing
emitted effects since scenarios were removed.
- `client.modules.call` resolves to `CallModuleFunctionClientResponse`, which - `client.modules.call` resolves to `CallModuleFunctionClientResponse`, which
carries only `result`. Console output, the kernel call log and `error` are carries only `result`. Console output, the kernel call log and `error` are
no longer part of the player response; they stay on the platform dry run. no longer part of the player response; they stay on the platform dry run.
+9 -8
View File
@@ -38,7 +38,7 @@ const profile = client.player.data;
const gold = profile?.wallets?.find((w) => w.currency === 'gold')?.balance; const gold = profile?.wallets?.find((w) => w.currency === 'gold')?.balance;
const items = await client.resources('items').list(); const items = await client.resources('items').list();
const result = await client.modules.call('quests', 'list'); const { result } = await client.modules.call('my_module', 'hello', { name: 'Bob' });
client.dispose(); client.dispose();
``` ```
@@ -47,7 +47,7 @@ client.dispose();
| Area | Access | | Area | Access |
|---|---| |---|---|
| Auth | `client.auth.loginWithDevice()`, `client.auth.loginWithCustom({ customData })`, `client.auth.logout()`, `client.auth.isAuthenticated`, `client.auth.onAuthStateChange(cb)` | | Auth | `client.auth.loginWithDevice()`, `loginWithGoogle({ idToken })`, `loginWithApple({ idToken })`, `loginWithCustom({ customData })`, `linkWithGoogle(idToken)`, `linkWithApple(idToken)`, `linkWithCustom({ customData })`, `unlinkIdentity(provider)`, `logout()`, `isAuthenticated`, `onAuthStateChange(cb)` |
| Player / wallets | `client.player` (observable) | | Player / wallets | `client.player` (observable) |
| Catalog currencies | `client.catalog` (observable) | | Catalog currencies | `client.catalog` (observable) |
| Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` | | Storage | `client.storage` (observable) + `.save(items)` / `.delete(type)` |
@@ -55,9 +55,10 @@ client.dispose();
| Modules | `client.modules.call(module, fn, args?)` | | Modules | `client.modules.call(module, fn, args?)` |
| Resources | `client.resources(slug)` → `list` / `get` / `create` / `update` / `delete` | | Resources | `client.resources(slug)` → `list` / `get` / `create` / `update` / `delete` |
Game-design features (quests, stores, inventory, leaderboards, battle pass, Game features are installed modules, not SDK services. The first-party
remote config) live in installed modules. Call them through modules are items, inventory, reward, remote_config, leaderboards, quests,
`client.modules.call` and read their rows through `client.resources(slug)`. store and battlepass. Call a module through `client.modules.call` and read its
rows through `client.resources(slug)`.
## Error handling ## Error handling
@@ -108,7 +109,7 @@ client.auth.onAuthStateChange((state) => {
Observable domains (`player`, `catalog`, `storage`, `projectStorage`) are Observable domains (`player`, `catalog`, `storage`, `projectStorage`) are
warmed at login (`player` and `catalog`) or on first use, then kept fresh by warmed at login (`player` and `catalog`) or on first use, then kept fresh by
a revision poll: the client asks the gateway for entity revisions every a revision poll: the client asks the backend for entity revisions every
**30 s (±20% jitter)** and reloads only the entities whose revision grew. **30 s (±20% jitter)** and reloads only the entities whose revision grew.
Polling **pauses while the tab is hidden** and resumes on `visibilitychange`. Polling **pauses while the tab is hidden** and resumes on `visibilitychange`.
Storage writes invalidate the affected domain immediately. Expect data to be Storage writes invalidate the affected domain immediately. Expect data to be
@@ -142,8 +143,8 @@ function Wallet() {
npm run typecheck # tsc over src and tests npm run typecheck # tsc over src and tests
npm test # unit + local e2e (jsdom) npm test # unit + local e2e (jsdom)
npm run build # tsup → dist (ESM + CJS + d.ts) npm run build # tsup → dist (ESM + CJS + d.ts)
npm run test:e2e-prod # drives a seeded project on the live gateway npm run test:e2e-prod # drives a seeded project on the live backend
``` ```
Generated wire types (`src/generated`) come from the gateway's `apigen`; run Generated wire types (`src/generated`) come from `liveops-backend/apigen`; run
`npm run generate` to refresh them. Do not edit them by hand. `npm run generate` to refresh them. Do not edit them by hand.
+1 -1
View File
@@ -39,7 +39,7 @@
"e2e:prod": "vitest run --config vitest.e2e-prod.config.ts", "e2e:prod": "vitest run --config vitest.e2e-prod.config.ts",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
"check:package": "npm run build && npm pack --dry-run --json", "check:package": "npm run build && npm pack --dry-run --json",
"generate": "make -C ../liveops-gateway generate-openapi" "generate": "make -C ../liveops-backend generate-openapi"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
+10 -8
View File
@@ -6,8 +6,8 @@ description: Use when working with the Rudder TypeScript/JavaScript SDK (@rudder
# Rudder Web SDK (@rudder/js-sdk) # Rudder Web SDK (@rudder/js-sdk)
Browser-facing player SDK for the Rudder LiveOps platform. Source of truth: Browser-facing player SDK for the Rudder LiveOps platform. Source of truth:
`liveops-web-sdk/src` (generated wire types in `src/generated` come from the `liveops-web-sdk/src` (generated wire types in `src/generated` come from
gateway's apigen — never edit them by hand). `liveops-backend/apigen` — never edit them by hand).
2.0 kernel surface: auth, player (wallets), catalog currencies, storage, 2.0 kernel surface: auth, player (wallets), catalog currencies, storage,
project storage, revision sync, `client.modules.call(module, fn, args)`, project storage, revision sync, `client.modules.call(module, fn, args)`,
@@ -40,7 +40,6 @@ const client = new RudderClient({
// tokenStore?: TokenStore — default: localStorage, in-memory fallback // tokenStore?: TokenStore — default: localStorage, in-memory fallback
// requestTimeoutMs?: number — default 10000 // requestTimeoutMs?: number — default 10000
// syncIntervalMs?: number — revision poll, default 30000 (±20% jitter) // syncIntervalMs?: number — revision poll, default 30000 (±20% jitter)
// onEffectError?: (error) => void — default console.error
}); });
``` ```
@@ -86,11 +85,12 @@ Tokens are saved to the `TokenStore` automatically. The transport injects
| Modules | `client.modules.call(module, fn, args?)` | `CallModuleFunctionClientResponse` (`{ result }`) | | Modules | `client.modules.call(module, fn, args?)` | `CallModuleFunctionClientResponse` (`{ result }`) |
| Resources | `client.resources(slug)` | `list` / `get` / `create` / `update` / `delete` | | Resources | `client.resources(slug)` | `list` / `get` / `create` / `update` / `delete` |
There is no UGC, wallet service, or standalone economy service — wallets live Wallets live on `client.player.data.wallets`. Game features are installed
on `client.player.data.wallets`. Quests, stores, inventory, leaderboards, modules, not SDK services (first-party modules: items, inventory, reward,
battle pass, and remote config are installed modules: call remote_config, leaderboards, quests, store, battlepass). Call them with
`client.modules.call('quests', 'list')` (etc.) and read rows with `client.modules.call('my_module', 'hello', { ... })` and read their rows with
`client.resources('items')`. `client.resources(slug)`. Details: [reference/modules.md](reference/modules.md),
[reference/resources.md](reference/resources.md).
## Observable domains — the shared pattern ## Observable domains — the shared pattern
@@ -137,3 +137,5 @@ documents. A module trap is a 500 and a timeout a 504, both without details.
- [reference/auth.md](reference/auth.md) — login flows, options, token store, session lifecycle - [reference/auth.md](reference/auth.md) — login flows, options, token store, session lifecycle
- [reference/player.md](reference/player.md) — profile + wallets (`PlayerProfile`, `Wallet`) - [reference/player.md](reference/player.md) — profile + wallets (`PlayerProfile`, `Wallet`)
- [reference/storage.md](reference/storage.md) — player + project storage, limits - [reference/storage.md](reference/storage.md) — player + project storage, limits
- [reference/modules.md](reference/modules.md) — `modules.call`, response, module errors
- [reference/resources.md](reference/resources.md) — `resources(slug)` row operations
@@ -0,0 +1,31 @@
# Modules
Source: `src/domains/ModulesDomain.ts`. Calls an installed module function as
the signed-in player through `POST /sdk/v1/modules/{module}/{fn}`.
```ts
client.modules.call(module: string, fn: string, args?: unknown): Promise<CallModuleFunctionClientResponse>
// CallModuleFunctionClientResponse = { result?: unknown }
```
```ts
const { result } = await client.modules.call('my_module', 'hello', { name: 'Bob' });
```
`args` is sent as `{ args }`. The response carries only `result`; console
output and the kernel call log stay on the platform dry run.
A business error raised by the module rejects with `RudderHttpError`
(`status` 422, body `{ error, code, requestId }`) whose `code` is the
module's own open string code; there are no generated constants for it. A
trap is a 500 and a timeout a 504, both without details.
```ts
try {
await client.modules.call('my_module', 'hello');
} catch (error) {
if (error instanceof RudderHttpError && error.status === 422) {
console.warn(error.code); // module-defined
}
}
```
@@ -0,0 +1,29 @@
# Resources
Source: `src/domains/ResourcesDomain.ts`. Row operations on a resource as the
signed-in player through `/sdk/v1/resources/{slug}`. `client.resources(slug)`
returns a `ResourceHandle`; it performs no request by itself.
```ts
const items = client.resources('items');
items.list(): Promise<ListResourceRowsResponse> // GET /{slug} → { items?: ResourceRow[], total?: number }
items.get(id): Promise<ResourceRow> // GET /{slug}/{id}
items.create({ id?, data? }): Promise<ResourceRow> // POST /{slug}
items.update(id, { data?, version? }): Promise<ResourceRow> // PATCH /{slug}/{id}
items.delete(id): Promise<void> // DELETE /{slug}/{id}
```
`ResourceRow` is `{ id, playerId, data, version, rank, createdAt, updatedAt }`
(all optional in the generated type). Pass `version` on `update` for
optimistic concurrency.
```ts
const row = await client.resources('items').create({ data: { name: 'Sword' } });
await client.resources('items').update(row.id!, { data: { name: 'Axe' }, version: row.version });
```
What a player may read or write is decided by the resource's access rules on
the server; a denied operation rejects with `RudderHttpError` `code`
`forbidden`. Deleting a row that other rows still reference rejects with
status 409 and `code` `row_referenced`. Resources are not observable domains:
call `list()` again when you need fresh rows.
-7
View File
@@ -18,7 +18,6 @@ import type { RudderContext } from '../core/context.js';
import { request, type RequestContext } from '../transport/request.js'; import { request, type RequestContext } from '../transport/request.js';
import { createDefaultTokenStore } from '../token/TokenStore.js'; import { createDefaultTokenStore } from '../token/TokenStore.js';
import { AuthService } from '../auth/AuthService.js'; import { AuthService } from '../auth/AuthService.js';
import { EffectsCenter, type Effects } from '../effects/EffectsCenter.js';
import { SyncEngine } from '../state/SyncEngine.js'; import { SyncEngine } from '../state/SyncEngine.js';
import { PlayerDomain } from '../domains/PlayerDomain.js'; import { PlayerDomain } from '../domains/PlayerDomain.js';
import { CatalogDomain } from '../domains/CatalogDomain.js'; import { CatalogDomain } from '../domains/CatalogDomain.js';
@@ -33,7 +32,6 @@ export class RudderClient {
public readonly options: ResolvedRudderClientOptions; public readonly options: ResolvedRudderClientOptions;
public readonly auth: AuthService; public readonly auth: AuthService;
public readonly effects: Effects;
public readonly player: PlayerDomain; public readonly player: PlayerDomain;
public readonly catalog: CatalogDomain; public readonly catalog: CatalogDomain;
@@ -42,7 +40,6 @@ export class RudderClient {
public readonly modules: ModulesDomain; public readonly modules: ModulesDomain;
private readonly resourcesDomain: ResourcesDomain; private readonly resourcesDomain: ResourcesDomain;
private readonly effectsCenter: EffectsCenter;
private readonly syncEngine: SyncEngine; private readonly syncEngine: SyncEngine;
private readonly ctx: RudderContext; private readonly ctx: RudderContext;
private readonly transportContext: RequestContext; private readonly transportContext: RequestContext;
@@ -74,12 +71,8 @@ export class RudderClient {
}, },
}; };
this.effectsCenter = new EffectsCenter(this.options.onEffectError);
this.effects = this.effectsCenter;
this.ctx = { this.ctx = {
options: this.options, options: this.options,
effects: this.effectsCenter,
request: <TResponse>(method: string, path: string, body?: unknown) => request: <TResponse>(method: string, path: string, body?: unknown) =>
request<TResponse>(method, path, body, this.transportContext), request<TResponse>(method, path, body, this.transportContext),
}; };
-6
View File
@@ -25,12 +25,6 @@ export interface RudderClientOptions {
/** Revision sync poll interval in milliseconds (default: 30000, ±20% jitter). */ /** Revision sync poll interval in milliseconds (default: 30000, ±20% jitter). */
syncIntervalMs?: number; syncIntervalMs?: number;
/**
* Called when an effect handler (or effect preparation) throws.
* Defaults to `console.error`.
*/
onEffectError?: (error: unknown) => void;
} }
/** Client options with all defaults resolved — what `client.options` exposes. */ /** Client options with all defaults resolved — what `client.options` exposes. */
+1 -3
View File
@@ -1,9 +1,8 @@
import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.js'; import type { ResolvedRudderClientOptions } from '../client/RudderClientOptions.js';
import type { EffectsCenter } from '../effects/EffectsCenter.js';
/** /**
* RudderContext — the low-level surface every feature service depends on: * RudderContext — the low-level surface every feature service depends on:
* the HTTP transport, client options, and the effects bus. * the HTTP transport and client options.
* *
* Built once by {@link RudderClient} and injected into each service. Services * Built once by {@link RudderClient} and injected into each service. Services
* depend on this narrow interface instead of on the client itself, so the * depend on this narrow interface instead of on the client itself, so the
@@ -11,6 +10,5 @@ import type { EffectsCenter } from '../effects/EffectsCenter.js';
*/ */
export interface RudderContext { export interface RudderContext {
readonly options: ResolvedRudderClientOptions; readonly options: ResolvedRudderClientOptions;
readonly effects: EffectsCenter;
request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>; request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>;
} }
-103
View File
@@ -1,103 +0,0 @@
export type EffectUnsubscribe = () => void;
export interface NotificationEffect {
readonly title: string;
readonly message: string;
done(): Promise<void>;
}
export interface WaitEffect {
readonly deadlineUtc: Date;
}
export interface ScenarioCompletedEffect {
readonly runId: string;
readonly scenarioSlug: string;
}
export interface ScenarioFailedEffect {
readonly runId: string;
readonly scenarioSlug: string;
readonly nodeId: string;
readonly error: Error;
}
type EffectHandler<TEffect> = (effect: TEffect) => void | Promise<void>;
export interface Effects {
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe;
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe;
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe;
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): EffectUnsubscribe;
}
export class EffectsCenter implements Effects {
private readonly notificationHandlers = new Set<EffectHandler<NotificationEffect>>();
private readonly waitHandlers = new Set<EffectHandler<WaitEffect>>();
private readonly scenarioCompletedHandlers = new Set<EffectHandler<ScenarioCompletedEffect>>();
private readonly scenarioFailedHandlers = new Set<EffectHandler<ScenarioFailedEffect>>();
constructor(
private readonly onError: (error: unknown) => void = (error) =>
console.error('[Rudder] Effect handler failed', error),
) {}
onNotification(handler: EffectHandler<NotificationEffect>): EffectUnsubscribe {
return this.addHandler(this.notificationHandlers, handler);
}
onWait(handler: EffectHandler<WaitEffect>): EffectUnsubscribe {
return this.addHandler(this.waitHandlers, handler);
}
onScenarioCompleted(handler: EffectHandler<ScenarioCompletedEffect>): EffectUnsubscribe {
return this.addHandler(this.scenarioCompletedHandlers, handler);
}
onScenarioFailed(handler: EffectHandler<ScenarioFailedEffect>): EffectUnsubscribe {
return this.addHandler(this.scenarioFailedHandlers, handler);
}
/** @internal */
emitNotification(effect: NotificationEffect): void {
this.emit(this.notificationHandlers, effect);
}
/** @internal */
emitWait(effect: WaitEffect): void {
this.emit(this.waitHandlers, effect);
}
/** @internal */
emitScenarioCompleted(effect: ScenarioCompletedEffect): void {
this.emit(this.scenarioCompletedHandlers, effect);
}
/** @internal */
emitScenarioFailed(effect: ScenarioFailedEffect): void {
this.emit(this.scenarioFailedHandlers, effect);
}
private addHandler<TEffect>(
handlers: Set<EffectHandler<TEffect>>,
handler: EffectHandler<TEffect>,
): EffectUnsubscribe {
handlers.add(handler);
return () => {
handlers.delete(handler);
};
}
private emit<TEffect>(
handlers: Set<EffectHandler<TEffect>>,
effect: TEffect,
): void {
for (const handler of handlers) {
try {
Promise.resolve(handler(effect)).catch((error) => this.onError(error));
} catch (error) {
this.onError(error);
}
}
}
}
-10
View File
@@ -45,16 +45,6 @@ export type { ResourcesDomain, ResourceHandle } from './domains/ResourcesDomain.
export type { StorageDomain } from './domains/StorageDomain.js'; export type { StorageDomain } from './domains/StorageDomain.js';
export type { ProjectStorageDomain } from './domains/ProjectStorageDomain.js'; export type { ProjectStorageDomain } from './domains/ProjectStorageDomain.js';
// Effects
export type {
Effects,
EffectUnsubscribe,
NotificationEffect,
WaitEffect,
ScenarioCompletedEffect,
ScenarioFailedEffect,
} from './effects/EffectsCenter.js';
// Generated wire types — the shapes returned by the observable state and // Generated wire types — the shapes returned by the observable state and
// handles, so consumers can name them instead of re-declaring mirrors. // handles, so consumers can name them instead of re-declaring mirrors.
export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js'; export type { Player, PlayerProfile, PlayerProfileIdentity, Wallet } from './generated/player.js';
+2 -14
View File
@@ -76,8 +76,8 @@ export class SyncedState<T> {
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return; if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
this.invalidateScheduled = true; this.invalidateScheduled = true;
queueMicrotask(() => { queueMicrotask(() => {
// A reset() or set() between scheduling and flushing cancels the reload // A reset() between scheduling and flushing cancels the reload
// (fresh data arrived, or the entity was dropped on logout). // (the entity was dropped on logout).
if (!this.invalidateScheduled) return; if (!this.invalidateScheduled) return;
this.invalidateScheduled = false; this.invalidateScheduled = false;
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return; if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
@@ -85,18 +85,6 @@ export class SyncedState<T> {
}); });
} }
/**
* Applies externally-provided data (e.g. scenario config patches).
*
* @internal
*/
set(data: T): void {
this.generation++;
this.pending = undefined;
this.invalidateScheduled = false;
this.setSnapshot({ status: 'ready', data });
}
/** /**
* Drops all state back to idle (logout). * Drops all state back to idle (logout).
* *
+1 -6
View File
@@ -71,7 +71,6 @@ describe('public API surface', () => {
expect(client.modules).toBeDefined(); expect(client.modules).toBeDefined();
expect(typeof client.modules.call).toBe('function'); expect(typeof client.modules.call).toBe('function');
expect(typeof client.resources).toBe('function'); expect(typeof client.resources).toBe('function');
expect(client.effects).toBeDefined();
expect('battlePass' in client).toBe(false); expect('battlePass' in client).toBe(false);
expect('quests' in client).toBe(false); expect('quests' in client).toBe(false);
@@ -80,10 +79,6 @@ describe('public API surface', () => {
expect('inventory' in client).toBe(false); expect('inventory' in client).toBe(false);
expect('remoteConfig' in client).toBe(false); expect('remoteConfig' in client).toBe(false);
expect('scenarios' in client).toBe(false); expect('scenarios' in client).toBe(false);
expect('onBattlePass' in client.effects).toBe(false); expect('effects' in client).toBe(false);
expect('onStoreOffer' in client.effects).toBe(false);
expect('onQuest' in client.effects).toBe(false);
expect('onLeaderboard' in client.effects).toBe(false);
expect('onConfigChanged' in client.effects).toBe(false);
}); });
}); });
+1 -42
View File
@@ -1,9 +1,7 @@
// Test-side helpers: load the seed artifact, build SDK clients against prod, and // Test-side helpers: load the seed artifact and build SDK clients against prod.
// reach the admin API for wallet grants / player inspection.
import { RudderClient } from '../../../src/client/RudderClient.js'; import { RudderClient } from '../../../src/client/RudderClient.js';
import type { TokenStore } from '../../../src/token/TokenStore.js'; import type { TokenStore } from '../../../src/token/TokenStore.js';
import { createAdminClient, type AdminClient } from './adminClient.js';
import { readArtifact } from './artifact.js'; import { readArtifact } from './artifact.js';
import type { SeedArtifact } from './types.js'; import type { SeedArtifact } from './types.js';
@@ -58,42 +56,3 @@ export async function freshPlayer(
await client.auth.loginWithDevice({ region: 'en', language: 'en' }); await client.auth.loginWithDevice({ region: 'en', language: 'en' });
return client; return client;
} }
/** Admin (operator) API client authenticated with the seeded operator token. */
export function adminApi(artifact: SeedArtifact = loadArtifact()): AdminClient {
const api = createAdminClient(artifact.baseUrl);
api.setToken(artifact.adminToken);
return api;
}
/** Credits a player's wallet through the admin batch endpoint. */
export async function grantCurrency(
artifact: SeedArtifact,
playerId: string,
currencyCode: string,
amount: number,
): Promise<void> {
await adminApi(artifact).post(
`/platform/v1/projects/${artifact.projectId}/players/wallet/adjust?environment=${artifact.environment}`,
{ items: [{ playerId, currencyCode, amount, reason: 'e2e grant' }] },
);
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Retries a read until ok(result) holds — absorbs release/cache reload lag. */
export async function withReadRetry<T>(
fn: () => Promise<T>,
ok: (value: T) => boolean,
tries = 8,
gapMs = 1500,
): Promise<T> {
let last = await fn();
if (ok(last)) return last;
for (let i = 1; i < tries; i++) {
await sleep(gapMs);
last = await fn();
if (ok(last)) return last;
}
return last;
}