From 17a55888a59af846ba16e336d07e2911c99a7505 Mon Sep 17 00:00:00 2001 From: edmand46 Date: Tue, 29 Sep 2026 16:43:15 +0300 Subject: [PATCH] feat!: client storage is read-only, storage.save and storage.delete removed --- CHANGELOG.md | 6 ++++ README.md | 4 +-- skills/rudder-web-sdk/SKILL.md | 2 +- skills/rudder-web-sdk/reference/storage.md | 9 ++--- src/domains/StorageDomain.ts | 19 +--------- src/generated/api.ts | 16 +-------- src/generated/storage.ts | 5 --- test/RudderClient.test.ts | 2 +- test/e2e-prod/storage.e2e.test.ts | 12 ++----- test/e2e/storage.e2e.test.ts | 42 ++++++---------------- test/helpers/fakeGateway.ts | 15 -------- 11 files changed, 28 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b7f87..3df2233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Breaking: `storage.save` and `storage.delete` are removed; the server no + longer accepts client storage writes. `client.storage` is read-only; module + functions and the admin API write player storage. + ## 0.2.1 - Docs only: README wording. diff --git a/README.md b/README.md index ac6aba7..f285816 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ const profile = client.player.value; const gold = profile?.balances?.find((b) => b.currency === 'gold')?.balance; const swords = profile?.items?.find((i) => i.item === 'sword')?.quantity ?? 0; -await client.storage.save('progress', JSON.stringify({ level: 7 })); +const progress = client.storage.value?.items?.find((i) => i.type === 'progress')?.data; client.dispose(); ``` @@ -92,7 +92,7 @@ can also call directly: | Auth | `client.auth.loginWithDevice()`, `loginWithGoogle({ idToken })`, `loginWithApple({ idToken })`, `loginWithCustom({ customData })`, `linkWithGoogle(idToken)`, `linkWithApple(idToken)`, `linkWithCustom({ customData })`, `unlinkIdentity(provider)`, `logout()`, `isAuthenticated`, `onAuthStateChange(cb)` | | Player | `client.player` state: `player`, `identities`, `balances`, `items` | | Catalog currencies | `client.catalog` state (`Map`) | -| Storage | `client.storage` state + `.save(type, data)` / `.delete(type)` | +| Storage | `client.storage` state, read-only (modules write it) | | Modules | `client.modules.call(module, fn, args?, { moduleVersion? })`, usually through a generated client | `balances` carries one entry per released currency and `items` one per diff --git a/skills/rudder-web-sdk/SKILL.md b/skills/rudder-web-sdk/SKILL.md index b180c37..f21233e 100644 --- a/skills/rudder-web-sdk/SKILL.md +++ b/skills/rudder-web-sdk/SKILL.md @@ -77,7 +77,7 @@ refresh clears tokens and emits `signed-out`. Details: | Auth | `client.auth` | service | | Player | `client.player` (`player`, `identities`, `balances`, `items`) | `SyncedState` | | Catalog currencies | `client.catalog` | `SyncedState>` | -| Player storage | `client.storage` + `.save(type, data)` / `.delete(type)` | `SyncedState` + mutations | +| Player storage | `client.storage`, read-only | `SyncedState` | | Modules | `client.modules.call(module, fn, args?, { moduleVersion? })` | resolves to `result` as `T` | ## Module calls through generated clients diff --git a/skills/rudder-web-sdk/reference/storage.md b/skills/rudder-web-sdk/reference/storage.md index 85e4cc4..34519a0 100644 --- a/skills/rudder-web-sdk/reference/storage.md +++ b/skills/rudder-web-sdk/reference/storage.md @@ -5,8 +5,7 @@ Per-player storage, one item per `type`. `StorageDomain`, revision key ```ts client.storage.value // GetStorageResponse | undefined -await client.storage.save(type, data); -await client.storage.delete(type); +await client.storage.reload(); ``` ```ts @@ -21,10 +20,8 @@ interface StorageItem { } ``` -- `save(type: string, data: string): Promise`: upserts the item of - `type` (the server keeps one item per player and type), then invalidates. -- `delete(type: string): Promise`: deletes the item of `type`, then - invalidates. +- The client cannot write storage. Module functions (kernel `Storage.Set`) and + the batch admin API write it; call `reload()` after a module call that saved. ## Limits and notes diff --git a/src/domains/StorageDomain.ts b/src/domains/StorageDomain.ts index 14a5903..12baaca 100644 --- a/src/domains/StorageDomain.ts +++ b/src/domains/StorageDomain.ts @@ -3,27 +3,10 @@ import type { RudderContext } from '../core/context.js'; import { api } from '../generated/api.js'; import type { GetStorageResponse } from '../generated/storage.js'; -/** - * StorageDomain — player storage (one item per type) as an observable entity, - * plus its mutations. Synced under revision key `storage`; mutations invalidate it so - * subscribers observe fresh data. - */ export class StorageDomain extends SyncedState { readonly syncKey = 'storage'; - constructor(private readonly ctx: RudderContext) { + constructor(ctx: RudderContext) { super(() => api.getStorage(ctx, { limit: 100 })); } - - /** Upserts the item of `type` with raw string `data`, then invalidates. */ - async save(type: string, data: string): Promise { - await api.updateStorage(this.ctx, { items: [{ type, data }] }); - this.invalidate(); - } - - /** Deletes the player storage item of `type`, then invalidates. */ - async delete(type: string): Promise { - await api.deleteStorage(this.ctx, { type }); - this.invalidate(); - } } diff --git a/src/generated/api.ts b/src/generated/api.ts index 277df93..38a1a5f 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -4,9 +4,8 @@ import type { LinkViaAppleRequest, LinkViaCustomRequest, LinkViaGoogleRequest, L import type { ListCatalogCurrenciesResponse } from './catalog.js'; import type { CallModuleFunctionClientResponse, CallModuleFunctionRequest } from './modules.js'; import type { PlayerProfile } from './player.js'; -import type { GetStorageResponse, UpdateStorageRequest } from './storage.js'; +import type { GetStorageResponse } from './storage.js'; -/** The minimal transport contract the generated API functions call through. */ export interface Transport { request(method: string, path: string, body?: unknown): Promise; } @@ -27,7 +26,6 @@ function qs( return parts.length === 0 ? '' : `?${parts.join('&')}`; } -/** Typed request functions, one per SDK operation. */ export const api = { callModuleFunction: ( t: Transport, @@ -36,12 +34,6 @@ export const api = { ): Promise => t.request('POST', `/sdk/v1/modules/${enc(path.module)}/${enc(path.fn)}`, body), - deleteStorage: ( - t: Transport, - query?: { type?: string }, - ): Promise => - t.request('DELETE', `/sdk/v1/storage${qs([['type', query?.type]])}`), - getPlayerInformation: ( t: Transport, ): Promise => @@ -117,10 +109,4 @@ export const api = { ): Promise => t.request('POST', '/sdk/v1/authorization/unlink', body), - updateStorage: ( - t: Transport, - body: UpdateStorageRequest, - ): Promise => - t.request('PUT', '/sdk/v1/storage', body), - }; diff --git a/src/generated/storage.ts b/src/generated/storage.ts index 28133c9..accbe3f 100644 --- a/src/generated/storage.ts +++ b/src/generated/storage.ts @@ -11,8 +11,3 @@ export interface StorageItem { "type"?: string; } -export interface UpdateStorageRequest { - "idempotencyKey"?: string; - "items"?: StorageItem[]; -} - diff --git a/test/RudderClient.test.ts b/test/RudderClient.test.ts index 1924151..c400faf 100644 --- a/test/RudderClient.test.ts +++ b/test/RudderClient.test.ts @@ -117,7 +117,7 @@ describe('transport error mapping', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response(null, { status: 204, statusText: 'No Content' }), )); - const result = await client.storage.save('t', '{}'); + const result = await client.auth.linkWithGoogle('id-token'); expect(result).toBeUndefined(); }); diff --git a/test/e2e-prod/storage.e2e.test.ts b/test/e2e-prod/storage.e2e.test.ts index 8a20b5c..5a9dad3 100644 --- a/test/e2e-prod/storage.e2e.test.ts +++ b/test/e2e-prod/storage.e2e.test.ts @@ -2,18 +2,10 @@ import { describe, it, expect } from 'vitest'; import { freshPlayer } from './_setup/harness.js'; describe('e2e-prod: storage', () => { - it('save -> get -> delete round-trip', async () => { + it('a fresh player reads empty storage', async () => { const client = await freshPlayer(); - const payload = JSON.stringify({ level: 7 }); - - await client.storage.save('savegame', payload); const got = await client.storage.reload(); - expect(got.items?.some((i) => i.type === 'savegame' && i.data === payload)).toBe(true); - - await client.storage.delete('savegame'); - - const after = await client.storage.reload(); - expect(after.items?.some((i) => i.type === 'savegame')).toBeFalsy(); + expect(got.items ?? []).toEqual([]); }); }); diff --git a/test/e2e/storage.e2e.test.ts b/test/e2e/storage.e2e.test.ts index 2594b66..f38d43f 100644 --- a/test/e2e/storage.e2e.test.ts +++ b/test/e2e/storage.e2e.test.ts @@ -8,56 +8,36 @@ describe('E2E: player storage', () => { let client: RudderClient; beforeEach(() => { - gateway = createFakeGateway(); + gateway = createFakeGateway({ + storage: new Map([ + ['progress', { id: 'id-progress', type: 'progress', data: JSON.stringify({ level: 7, gold: 1200 }) }], + ['prefs', { id: 'id-prefs', type: 'prefs', data: JSON.stringify({ sound: false }) }], + ]), + }); gateway.install(); client = createTestClient(); }); afterEach(() => vi.unstubAllGlobals()); - it('saves items then reads them back (round-trip through the gateway)', async () => { + it('reads the items written for the player', async () => { await client.auth.loginWithDevice(); - await client.storage.save('progress', JSON.stringify({ level: 7, gold: 1200 })); - await client.storage.save('prefs', JSON.stringify({ sound: false })); - const all = await client.storage.reload(); expect(all.items).toHaveLength(2); const progress = all.items!.find((i) => i.type === 'progress'); expect(JSON.parse(progress!.data!)).toEqual({ level: 7, gold: 1200 }); - const put = gateway.recorded.find((r) => r.method === 'PUT' && r.path === '/sdk/v1/storage'); - expect(put?.body).toEqual({ items: [{ type: 'progress', data: '{"level":7,"gold":1200}' }] }); - const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage'); expect(getReq?.query.get('limit')).toBe('100'); }); - it('save overwrites the item of the same type', async () => { - await client.auth.loginWithDevice(); - await client.storage.save('progress', '1'); - await client.storage.save('progress', '2'); - const all = await client.storage.reload(); - expect(all.items?.map((i) => i.data)).toEqual(['2']); - }); - - it('deletes a type and the item is gone', async () => { - await client.auth.loginWithDevice(); - await client.storage.save('progress', '{}'); - await client.storage.save('prefs', '{}'); - - await client.storage.delete('progress'); - - const remaining = await client.storage.reload(); - expect(remaining.items?.map((i) => i.type)).toEqual(['prefs']); - }); - - it('storage writes carry the auth token', async () => { + it('storage reads carry the auth token', async () => { const token = (await client.auth.loginWithDevice()).accessToken; - await client.storage.save('t', '{}'); + await client.storage.reload(); - const put = gateway.recorded.find((r) => r.method === 'PUT' && r.path === '/sdk/v1/storage'); - expect(put?.authToken).toBe(token); + const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage'); + expect(getReq?.authToken).toBe(token); }); }); diff --git a/test/helpers/fakeGateway.ts b/test/helpers/fakeGateway.ts index 1a9573b..5c23264 100644 --- a/test/helpers/fakeGateway.ts +++ b/test/helpers/fakeGateway.ts @@ -15,7 +15,6 @@ export interface RecordedRequest { export interface FakeGatewayState { storage: Map; currencies: CatalogCurrency[]; - /** Response per `module/fn`; falls back to `{ result: {} }`. */ moduleCalls: Map; } @@ -32,7 +31,6 @@ function json(body: unknown, status = 200): Response { }); } -const noContent = (): Response => new Response(null, { status: 204 }); export function createFakeGateway(seed?: Partial): FakeGateway { const state: FakeGatewayState = { @@ -79,19 +77,6 @@ export function createFakeGateway(seed?: Partial): FakeGateway ); return json({ items, nextCursor: '' }); } - if (method === 'PUT') { - const items = (body as { items?: StorageItem[] }).items ?? []; - for (const item of items) { - if (item.type) state.storage.set(item.type, { id: `id-${item.type}`, ...item }); - } - return noContent(); - } - if (method === 'DELETE') { - const type = query.get('type'); - if (type) state.storage.delete(type); - else state.storage.clear(); - return noContent(); - } } return json({ error: `unhandled route: ${method} ${path}` }, 500);