feat!: client storage is read-only, storage.save and storage.delete removed

This commit is contained in:
edmand46
2026-09-29 16:43:15 +03:00
parent b115450715
commit 17a55888a5
11 changed files with 28 additions and 104 deletions
+6
View File
@@ -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.
+2 -2
View File
@@ -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<slug, CatalogCurrency>`) |
| Storage | `client.storage` state + `.save(type, data)` / `.delete(type)` |
| Storage | `client.storage` state, read-only (modules write it) |
| Modules | `client.modules.call<T>(module, fn, args?, { moduleVersion? })`, usually through a generated client |
`balances` carries one entry per released currency and `items` one per
+1 -1
View File
@@ -77,7 +77,7 @@ refresh clears tokens and emits `signed-out`. Details:
| Auth | `client.auth` | service |
| Player | `client.player` (`player`, `identities`, `balances`, `items`) | `SyncedState<PlayerProfile>` |
| Catalog currencies | `client.catalog` | `SyncedState<Map<string, CatalogCurrency>>` |
| Player storage | `client.storage` + `.save(type, data)` / `.delete(type)` | `SyncedState<GetStorageResponse>` + mutations |
| Player storage | `client.storage`, read-only | `SyncedState<GetStorageResponse>` |
| Modules | `client.modules.call<T>(module, fn, args?, { moduleVersion? })` | resolves to `result` as `T` |
## Module calls through generated clients
+3 -6
View File
@@ -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<void>`: upserts the item of
`type` (the server keeps one item per player and type), then invalidates.
- `delete(type: string): Promise<void>`: 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
+1 -18
View File
@@ -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<GetStorageResponse> {
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<void> {
await api.updateStorage(this.ctx, { items: [{ type, data }] });
this.invalidate();
}
/** Deletes the player storage item of `type`, then invalidates. */
async delete(type: string): Promise<void> {
await api.deleteStorage(this.ctx, { type });
this.invalidate();
}
}
+1 -15
View File
@@ -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<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>;
}
@@ -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<CallModuleFunctionClientResponse> =>
t.request<CallModuleFunctionClientResponse>('POST', `/sdk/v1/modules/${enc(path.module)}/${enc(path.fn)}`, body),
deleteStorage: (
t: Transport,
query?: { type?: string },
): Promise<void> =>
t.request<void>('DELETE', `/sdk/v1/storage${qs([['type', query?.type]])}`),
getPlayerInformation: (
t: Transport,
): Promise<PlayerProfile> =>
@@ -117,10 +109,4 @@ export const api = {
): Promise<void> =>
t.request<void>('POST', '/sdk/v1/authorization/unlink', body),
updateStorage: (
t: Transport,
body: UpdateStorageRequest,
): Promise<void> =>
t.request<void>('PUT', '/sdk/v1/storage', body),
};
-5
View File
@@ -11,8 +11,3 @@ export interface StorageItem {
"type"?: string;
}
export interface UpdateStorageRequest {
"idempotencyKey"?: string;
"items"?: StorageItem[];
}
+1 -1
View File
@@ -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();
});
+2 -10
View File
@@ -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([]);
});
});
+11 -31
View File
@@ -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);
});
});
-15
View File
@@ -15,7 +15,6 @@ export interface RecordedRequest {
export interface FakeGatewayState {
storage: Map<string, StorageItem>;
currencies: CatalogCurrency[];
/** Response per `module/fn`; falls back to `{ result: {} }`. */
moduleCalls: Map<string, CallModuleFunctionClientResponse>;
}
@@ -32,7 +31,6 @@ function json(body: unknown, status = 200): Response {
});
}
const noContent = (): Response => new Response(null, { status: 204 });
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
const state: FakeGatewayState = {
@@ -79,19 +77,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): 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);