feat!: client storage is read-only, storage.save and storage.delete removed
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
};
|
||||
|
||||
@@ -11,8 +11,3 @@ export interface StorageItem {
|
||||
"type"?: string;
|
||||
}
|
||||
|
||||
export interface UpdateStorageRequest {
|
||||
"idempotencyKey"?: string;
|
||||
"items"?: StorageItem[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
Reference in New Issue
Block a user