Initial commit
CI / check (push) Successful in 56s

This commit is contained in:
rudder
2026-08-12 14:02:25 +03:00
commit 3ab2d4a6cf
85 changed files with 10257 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
import type { RemoteConfig } from '../generated/remote-config.js';
import { SyncedState } from './SyncedState.js';
export type RemoteConfigShape = Record<string, unknown>;
type RemoteConfigKey<TConfig extends RemoteConfigShape> = Extract<keyof TConfig, string>;
/**
* RemoteConfigState — typed remote configuration as an observable entity.
*
* `get()` parses values synchronously from the loaded snapshot based on their
* declared `valueType` and returns the default value while not loaded.
*/
export class RemoteConfigState<
TConfig extends RemoteConfigShape = RemoteConfigShape,
> extends SyncedState<Map<string, RemoteConfig>> {
get<TKey extends RemoteConfigKey<TConfig>>(key: TKey): TConfig[TKey] | undefined;
get<TKey extends RemoteConfigKey<TConfig>>(key: TKey, defaultValue: TConfig[TKey]): TConfig[TKey];
get<TKey extends RemoteConfigKey<TConfig>>(
key: TKey,
defaultValue?: TConfig[TKey],
): TConfig[TKey] | undefined {
const config = this.data?.get(key);
if (!config || config.value == null) return defaultValue;
return parseValue(config.value, config.valueType, defaultValue);
}
/**
* Applies a scenario remote_config_override patch to the current snapshot.
*
* @internal
*/
applyOverride(key: string, value: string, valueType = 'json'): void {
const next = new Map(this.data ?? []);
next.set(key, {
key,
value,
valueType,
active: true,
} as RemoteConfig);
this.set(next);
}
}
function parseValue<T>(
value: string,
valueType: string | undefined,
defaultValue: T | undefined,
): T | undefined {
try {
switch ((valueType ?? '').toLowerCase()) {
case 'int':
case 'integer':
return Number.parseInt(value, 10) as unknown as T;
case 'float':
case 'double':
case 'number':
return Number.parseFloat(value) as unknown as T;
case 'bool':
case 'boolean':
return (value === 'true') as unknown as T;
case 'json':
case 'object':
return JSON.parse(value) as T;
default:
return value as unknown as T;
}
} catch {
return defaultValue;
}
}
+150
View File
@@ -0,0 +1,150 @@
import type { RudderContext } from '../core/context.js';
import { api } from '../generated/api.js';
export const DEFAULT_SYNC_INTERVAL_MS = 30_000;
const REVISIONS_STORAGE_KEY = 'rudder_revisions';
const JITTER_RATIO = 0.2;
/** A domain the sync engine can refresh when its server revision grows. */
export interface SyncableDomain {
readonly syncKey: string;
invalidate(): void;
}
/**
* SyncEngine — polls GET /sdk/v1/sync and invalidates state entities whose
* server-side revision grew.
*
* The first successful sync after login only fixes the baseline (entities
* were just warmed); revisions are persisted to localStorage and cleared on
* logout. Polling pauses while the document is hidden, and poll errors never
* stop the loop (401s are handled by the transport refresh flow).
*/
export class SyncEngine {
private timer: ReturnType<typeof setTimeout> | undefined;
private revisions: Record<string, number> = {};
private baselineEstablished = false;
private running = false;
private polling = false;
private readonly byKey: Map<string, SyncableDomain>;
constructor(
private readonly ctx: RudderContext,
domains: readonly SyncableDomain[],
private readonly intervalMs: number = DEFAULT_SYNC_INTERVAL_MS,
) {
this.byKey = new Map(domains.map((domain) => [domain.syncKey, domain]));
}
start(): void {
if (this.running) return;
this.running = true;
this.revisions = readPersistedRevisions();
this.baselineEstablished = false;
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.onVisibilityChange);
}
void this.poll();
}
stop(): void {
this.running = false;
if (this.timer) {
clearTimeout(this.timer);
this.timer = undefined;
}
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.onVisibilityChange);
}
this.revisions = {};
this.baselineEstablished = false;
clearPersistedRevisions();
}
private readonly onVisibilityChange = (): void => {
if (this.running && !document.hidden) {
void this.poll();
}
};
private schedule(): void {
if (!this.running) return;
if (this.timer) clearTimeout(this.timer);
const jitter = 1 + (Math.random() * 2 - 1) * JITTER_RATIO;
this.timer = setTimeout(() => {
void this.poll();
}, this.intervalMs * jitter);
}
private async poll(): Promise<void> {
if (!this.running || this.polling) return;
if (typeof document !== 'undefined' && document.hidden) {
this.schedule();
return;
}
this.polling = true;
try {
const revisions = await api.getRevisions(this.ctx);
this.applyRevisions(revisions ?? {});
} catch {
// Network/server errors must not stop the loop.
} finally {
this.polling = false;
}
this.schedule();
}
private applyRevisions(next: Record<string, number>): void {
// On the first sync of a session, only establish the baseline UNLESS we
// restored revisions persisted by a previous session — in that case diff
// against them so entities changed while the tab was gone are refreshed.
const skipDiff = !this.baselineEstablished && Object.keys(this.revisions).length === 0;
this.baselineEstablished = true;
if (!skipDiff) {
for (const [key, revision] of Object.entries(next)) {
if (typeof revision !== 'number') continue;
if (revision > (this.revisions[key] ?? 0)) {
this.invalidate(key);
}
}
}
this.revisions = next;
persistRevisions(next);
}
private invalidate(key: string): void {
// Unknown keys have no registered domain and are ignored.
this.byKey.get(key)?.invalidate();
}
}
function readPersistedRevisions(): Record<string, number> {
try {
const raw = localStorage.getItem(REVISIONS_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object'
? (parsed as Record<string, number>)
: {};
} catch {
return {};
}
}
function persistRevisions(revisions: Record<string, number>): void {
try {
localStorage.setItem(REVISIONS_STORAGE_KEY, JSON.stringify(revisions));
} catch {
// Storage unavailable — best effort.
}
}
function clearPersistedRevisions(): void {
try {
localStorage.removeItem(REVISIONS_STORAGE_KEY);
} catch {
// Storage unavailable — silently ignore.
}
}
+152
View File
@@ -0,0 +1,152 @@
/**
* SyncedState — an observable cell for a single server-synced entity.
*
* Tracks `{ status, data, error }`, deduplicates parallel loads, and notifies
* listeners on every data/status change. `onChange` calls the callback
* immediately with the current snapshot, which makes syncing into external
* stores (React/zustand) trivial.
*/
export type SyncedStatus = 'idle' | 'loading' | 'ready' | 'error';
export interface SyncedSnapshot<T> {
status: SyncedStatus;
data?: T;
error?: Error;
}
export type SyncedListener<T> = (snapshot: SyncedSnapshot<T>) => void;
export class SyncedState<T> {
private snapshot: SyncedSnapshot<T> = { status: 'idle' };
private pending: Promise<T> | undefined;
private generation = 0;
private invalidateScheduled = false;
private readonly listeners = new Set<SyncedListener<T>>();
constructor(private readonly loader: () => Promise<T>) {}
/** Current data, or undefined when not loaded yet. */
get data(): T | undefined {
return this.snapshot.data;
}
get status(): SyncedStatus {
return this.snapshot.status;
}
get error(): Error | undefined {
return this.snapshot.error;
}
/**
* Subscribes to snapshot changes. The callback fires immediately with the
* current snapshot. Returns an unsubscribe function.
*/
onChange(callback: SyncedListener<T>): () => void {
this.listeners.add(callback);
callback(this.snapshot);
return () => {
this.listeners.delete(callback);
};
}
/** Loads the entity if needed; deduplicates parallel loads. */
load(): Promise<T> {
if (this.snapshot.status === 'ready') {
return Promise.resolve(this.snapshot.data as T);
}
return this.startLoad(false);
}
/** Forces a (re)load even when data is already present. */
reload(): Promise<T> {
return this.startLoad(true);
}
/**
* Refetches when the entity is in use (has listeners or was loaded before);
* otherwise no-ops. Coalesced: multiple invalidations within the same tick
* (e.g. one mutation touching several boundaries) trigger a single reload.
*
* @internal
*/
invalidate(): void {
if (this.invalidateScheduled) return;
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
this.invalidateScheduled = true;
queueMicrotask(() => {
// A reset() or set() between scheduling and flushing cancels the reload
// (fresh data arrived, or the entity was dropped on logout).
if (!this.invalidateScheduled) return;
this.invalidateScheduled = false;
if (this.listeners.size === 0 && this.snapshot.status === 'idle') return;
this.startLoad(true).catch(() => {});
});
}
/**
* 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).
*
* @internal
*/
reset(): void {
this.generation++;
this.pending = undefined;
this.invalidateScheduled = false;
this.setSnapshot({ status: 'idle' });
}
private startLoad(force: boolean): Promise<T> {
if (this.pending && !force) return this.pending;
const generation = ++this.generation;
this.setSnapshot({ status: 'loading', data: this.snapshot.data });
this.pending = this.loader()
.then((data) => {
if (generation === this.generation) {
this.setSnapshot({ status: 'ready', data });
}
return data;
})
.catch((err) => {
const error = err instanceof Error ? err : new Error(String(err));
if (generation === this.generation) {
this.setSnapshot({ status: 'error', error, data: this.snapshot.data });
}
throw error;
})
.finally(() => {
if (generation === this.generation) {
this.pending = undefined;
}
});
return this.pending;
}
private setSnapshot(next: SyncedSnapshot<T>): void {
const previous = this.snapshot;
if (
previous.status === next.status &&
previous.data === next.data &&
previous.error === next.error
) {
return;
}
this.snapshot = next;
for (const callback of this.listeners) {
callback(next);
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { CatalogItem } from '../generated/catalog.js';
import type { PlayerInventoryItem } from '../generated/inventory.js';
/** A fully-resolved owned item: player inventory row merged with its catalog entry. */
export interface InventoryItem {
slug: string;
amount: number;
name: string;
properties: Record<string, unknown>;
tags: string[];
}
/** @internal Merges owned inventory rows with their catalog entries. */
export function mergeInventory(
owned: PlayerInventoryItem[],
catalog: Map<string, CatalogItem>,
): InventoryItem[] {
return owned.map((item) => {
const slug = item.slug ?? '';
const entry = catalog.get(slug);
return {
slug,
amount: item.amount ?? 0,
name: item.nameOverride || entry?.name || slug,
properties: { ...(entry?.properties ?? {}), ...(item.propertiesOverride ?? {}) },
tags: entry?.tags ?? [],
};
});
}
+72
View File
@@ -0,0 +1,72 @@
import type {
Offer,
OfferContent,
OfferPrice,
Store,
PurchaseOfferResponse,
} from '../generated/stores.js';
export interface BuyOptions {
idempotencyKey?: string;
}
/**
* Wire-level purchase executor provided by the stores domain
* (`StoresDomain.purchase`).
*/
export type PurchaseOffer = (
storeSlug: string,
offerId: string,
options?: BuyOptions,
) => Promise<PurchaseOfferResponse>;
export class OfferHandle {
public readonly id: string;
public readonly name?: string;
public readonly price?: OfferPrice;
public readonly contents: OfferContent[];
public readonly maxPurchases?: number;
constructor(
private readonly purchase: PurchaseOffer,
private readonly storeSlug: string,
private readonly offer: Offer,
) {
if (!offer.id) {
throw new Error('Rudder Stores: offer.id is required');
}
this.id = offer.id;
this.name = offer.name;
this.price = offer.price;
this.contents = offer.contents ?? [];
this.maxPurchases = offer.maxPurchases;
}
async buy(options: BuyOptions = {}): Promise<PurchaseOfferResponse> {
return this.purchase(this.storeSlug, this.id, options);
}
}
export class ShopHandle {
public readonly slug: string;
public readonly name?: string;
public readonly description?: string;
public readonly data?: { [key: string]: unknown };
public readonly offers: OfferHandle[];
constructor(
purchase: PurchaseOffer,
store: Store,
) {
if (!store.slug) {
throw new Error('Rudder Stores: store.slug is required');
}
this.slug = store.slug;
this.name = store.name;
this.description = store.description;
this.data = store.data;
this.offers = (store.offers ?? []).map(
(offer) => new OfferHandle(purchase, this.slug, offer),
);
}
}