feat!: client storage is read-only, storage.save and storage.delete removed
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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
|
## 0.2.1
|
||||||
|
|
||||||
- Docs only: README wording.
|
- Docs only: README wording.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const profile = client.player.value;
|
|||||||
const gold = profile?.balances?.find((b) => b.currency === 'gold')?.balance;
|
const gold = profile?.balances?.find((b) => b.currency === 'gold')?.balance;
|
||||||
const swords = profile?.items?.find((i) => i.item === 'sword')?.quantity ?? 0;
|
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();
|
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)` |
|
| 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` |
|
| Player | `client.player` state: `player`, `identities`, `balances`, `items` |
|
||||||
| Catalog currencies | `client.catalog` state (`Map<slug, CatalogCurrency>`) |
|
| 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 |
|
| 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
|
`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 |
|
| Auth | `client.auth` | service |
|
||||||
| Player | `client.player` (`player`, `identities`, `balances`, `items`) | `SyncedState<PlayerProfile>` |
|
| Player | `client.player` (`player`, `identities`, `balances`, `items`) | `SyncedState<PlayerProfile>` |
|
||||||
| Catalog currencies | `client.catalog` | `SyncedState<Map<string, CatalogCurrency>>` |
|
| 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` |
|
| Modules | `client.modules.call<T>(module, fn, args?, { moduleVersion? })` | resolves to `result` as `T` |
|
||||||
|
|
||||||
## Module calls through generated clients
|
## Module calls through generated clients
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ Per-player storage, one item per `type`. `StorageDomain`, revision key
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
client.storage.value // GetStorageResponse | undefined
|
client.storage.value // GetStorageResponse | undefined
|
||||||
await client.storage.save(type, data);
|
await client.storage.reload();
|
||||||
await client.storage.delete(type);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -21,10 +20,8 @@ interface StorageItem {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `save(type: string, data: string): Promise<void>`: upserts the item of
|
- The client cannot write storage. Module functions (kernel `Storage.Set`) and
|
||||||
`type` (the server keeps one item per player and type), then invalidates.
|
the batch admin API write it; call `reload()` after a module call that saved.
|
||||||
- `delete(type: string): Promise<void>`: deletes the item of `type`, then
|
|
||||||
invalidates.
|
|
||||||
|
|
||||||
## Limits and notes
|
## Limits and notes
|
||||||
|
|
||||||
|
|||||||
@@ -3,27 +3,10 @@ import type { RudderContext } from '../core/context.js';
|
|||||||
import { api } from '../generated/api.js';
|
import { api } from '../generated/api.js';
|
||||||
import type { GetStorageResponse } from '../generated/storage.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> {
|
export class StorageDomain extends SyncedState<GetStorageResponse> {
|
||||||
readonly syncKey = 'storage';
|
readonly syncKey = 'storage';
|
||||||
|
|
||||||
constructor(private readonly ctx: RudderContext) {
|
constructor(ctx: RudderContext) {
|
||||||
super(() => api.getStorage(ctx, { limit: 100 }));
|
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 { ListCatalogCurrenciesResponse } from './catalog.js';
|
||||||
import type { CallModuleFunctionClientResponse, CallModuleFunctionRequest } from './modules.js';
|
import type { CallModuleFunctionClientResponse, CallModuleFunctionRequest } from './modules.js';
|
||||||
import type { PlayerProfile } from './player.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 {
|
export interface Transport {
|
||||||
request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>;
|
request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>;
|
||||||
}
|
}
|
||||||
@@ -27,7 +26,6 @@ function qs(
|
|||||||
return parts.length === 0 ? '' : `?${parts.join('&')}`;
|
return parts.length === 0 ? '' : `?${parts.join('&')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Typed request functions, one per SDK operation. */
|
|
||||||
export const api = {
|
export const api = {
|
||||||
callModuleFunction: (
|
callModuleFunction: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
@@ -36,12 +34,6 @@ export const api = {
|
|||||||
): Promise<CallModuleFunctionClientResponse> =>
|
): Promise<CallModuleFunctionClientResponse> =>
|
||||||
t.request<CallModuleFunctionClientResponse>('POST', `/sdk/v1/modules/${enc(path.module)}/${enc(path.fn)}`, body),
|
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: (
|
getPlayerInformation: (
|
||||||
t: Transport,
|
t: Transport,
|
||||||
): Promise<PlayerProfile> =>
|
): Promise<PlayerProfile> =>
|
||||||
@@ -117,10 +109,4 @@ export const api = {
|
|||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
t.request<void>('POST', '/sdk/v1/authorization/unlink', body),
|
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;
|
"type"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStorageRequest {
|
|
||||||
"idempotencyKey"?: string;
|
|
||||||
"items"?: StorageItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ describe('transport error mapping', () => {
|
|||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
new Response(null, { status: 204, statusText: 'No Content' }),
|
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();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,10 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { freshPlayer } from './_setup/harness.js';
|
import { freshPlayer } from './_setup/harness.js';
|
||||||
|
|
||||||
describe('e2e-prod: storage', () => {
|
describe('e2e-prod: storage', () => {
|
||||||
it('save -> get -> delete round-trip', async () => {
|
it('a fresh player reads empty storage', async () => {
|
||||||
const client = await freshPlayer();
|
const client = await freshPlayer();
|
||||||
const payload = JSON.stringify({ level: 7 });
|
|
||||||
|
|
||||||
await client.storage.save('savegame', payload);
|
|
||||||
|
|
||||||
const got = await client.storage.reload();
|
const got = await client.storage.reload();
|
||||||
expect(got.items?.some((i) => i.type === 'savegame' && i.data === payload)).toBe(true);
|
expect(got.items ?? []).toEqual([]);
|
||||||
|
|
||||||
await client.storage.delete('savegame');
|
|
||||||
|
|
||||||
const after = await client.storage.reload();
|
|
||||||
expect(after.items?.some((i) => i.type === 'savegame')).toBeFalsy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,56 +8,36 @@ describe('E2E: player storage', () => {
|
|||||||
let client: RudderClient;
|
let client: RudderClient;
|
||||||
|
|
||||||
beforeEach(() => {
|
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();
|
gateway.install();
|
||||||
client = createTestClient();
|
client = createTestClient();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
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.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();
|
const all = await client.storage.reload();
|
||||||
expect(all.items).toHaveLength(2);
|
expect(all.items).toHaveLength(2);
|
||||||
|
|
||||||
const progress = all.items!.find((i) => i.type === 'progress');
|
const progress = all.items!.find((i) => i.type === 'progress');
|
||||||
expect(JSON.parse(progress!.data!)).toEqual({ level: 7, gold: 1200 });
|
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');
|
const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage');
|
||||||
expect(getReq?.query.get('limit')).toBe('100');
|
expect(getReq?.query.get('limit')).toBe('100');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('save overwrites the item of the same type', async () => {
|
it('storage reads carry the auth token', 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 () => {
|
|
||||||
const token = (await client.auth.loginWithDevice()).accessToken;
|
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');
|
const getReq = gateway.recorded.findLast((r) => r.method === 'GET' && r.path === '/sdk/v1/storage');
|
||||||
expect(put?.authToken).toBe(token);
|
expect(getReq?.authToken).toBe(token);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export interface RecordedRequest {
|
|||||||
export interface FakeGatewayState {
|
export interface FakeGatewayState {
|
||||||
storage: Map<string, StorageItem>;
|
storage: Map<string, StorageItem>;
|
||||||
currencies: CatalogCurrency[];
|
currencies: CatalogCurrency[];
|
||||||
/** Response per `module/fn`; falls back to `{ result: {} }`. */
|
|
||||||
moduleCalls: Map<string, CallModuleFunctionClientResponse>;
|
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 {
|
export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway {
|
||||||
const state: FakeGatewayState = {
|
const state: FakeGatewayState = {
|
||||||
@@ -79,19 +77,6 @@ export function createFakeGateway(seed?: Partial<FakeGatewayState>): FakeGateway
|
|||||||
);
|
);
|
||||||
return json({ items, nextCursor: '' });
|
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);
|
return json({ error: `unhandled route: ${method} ${path}` }, 500);
|
||||||
|
|||||||
Reference in New Issue
Block a user