@proulr/bff-core 0.1.12 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proulr/bff-core",
3
- "version": "0.1.12",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -33,8 +33,8 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@hono/node-server": "^1.19.6",
36
- "@proulr/rosetta": "^0.1.70",
37
- "@proulr/sdui-contract": "^0.1.17",
36
+ "@proulr/rosetta": "^0.1.80",
37
+ "@proulr/sdui-contract": "^0.1.21",
38
38
  "@supabase/supabase-js": "^2.107.0",
39
39
  "dotenv": "^16.6.1",
40
40
  "hono": "^4.10.6",
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { gunzipSync } from 'node:zlib';
2
3
 
3
4
  type ServeCallback = (info: { address: string; family: string; port: number }) => void;
4
5
 
@@ -14,28 +15,49 @@ vi.mock('@hono/node-server', () => ({
14
15
 
15
16
  import { createSduiBff } from './index';
16
17
 
18
+ /** Contador de builders por screenId — prova reuso de cache e escopo do purge. */
19
+ const buildCounts: Record<string, number> = {};
20
+
17
21
  function createTestApp(overrides?: {
18
22
  metricsEnv?: string;
23
+ ttlEnv?: string;
19
24
  actions?: Parameters<typeof createSduiBff>[0]['actions'];
20
25
  }) {
21
26
  if (overrides?.metricsEnv !== undefined) {
22
27
  vi.stubEnv('BFF_METRICS', overrides.metricsEnv);
23
28
  }
29
+ if (overrides?.ttlEnv !== undefined) {
30
+ vi.stubEnv('BFF_SCREEN_CACHE_TTL_MS', overrides.ttlEnv);
31
+ }
32
+ Object.keys(buildCounts).forEach((key) => delete buildCounts[key]);
24
33
  return createSduiBff({
25
34
  name: 'Integration BFF',
26
35
  port: 9999,
27
36
  bffId: 'integration-bff',
28
37
  screens: {
29
- 'demo.home': async (ctx) => ({
30
- schema_version: 1,
31
- screen_id: 'demo.home',
32
- blocks: [],
33
- data: {
34
- auth_token: ctx.authToken ?? null,
35
- locale: ctx.query?.locale ?? null,
36
- },
37
- meta: { bff: 'integration-bff' },
38
- }),
38
+ 'demo.home': async (ctx) => {
39
+ buildCounts['demo.home'] = (buildCounts['demo.home'] ?? 0) + 1;
40
+ return {
41
+ schema_version: 1,
42
+ screen_id: 'demo.home',
43
+ blocks: [],
44
+ data: {
45
+ auth_token: ctx.authToken ?? null,
46
+ locale: ctx.query?.locale ?? null,
47
+ },
48
+ meta: { bff: 'integration-bff' },
49
+ };
50
+ },
51
+ // Acima do threshold (1 KB) do hono/compress — corpo pequeno é servido identity.
52
+ 'demo.big': async () => {
53
+ buildCounts['demo.big'] = (buildCounts['demo.big'] ?? 0) + 1;
54
+ return {
55
+ schema_version: 1,
56
+ screen_id: 'demo.big',
57
+ blocks: [],
58
+ data: { filler: 'proulr-'.repeat(400) },
59
+ };
60
+ },
39
61
  },
40
62
  actions: overrides?.actions ?? {
41
63
  ping: async (_ctx, body) => ({ echo: body }),
@@ -68,7 +90,7 @@ describe('createSduiBff HTTP integration', () => {
68
90
  ok: true,
69
91
  bff: 'integration-bff',
70
92
  name: 'Integration BFF',
71
- screens: ['demo.home'],
93
+ screens: ['demo.home', 'demo.big'],
72
94
  metrics_enabled: true,
73
95
  });
74
96
  });
@@ -84,6 +106,116 @@ describe('createSduiBff HTTP integration', () => {
84
106
  expect(body.data).toMatchObject({ auth_token: 'tok-abc', locale: 'pt-BR' });
85
107
  });
86
108
 
109
+ it('reuses the cached wire within the default TTL and answers 304 on If-None-Match', async () => {
110
+ const { app } = createTestApp();
111
+
112
+ const first = await app.request('/v1/screens/demo.home', {
113
+ headers: { Authorization: 'Bearer tok-reuse' },
114
+ });
115
+ const etag = first.headers.get('ETag');
116
+ expect(etag).toMatch(/^W\/"/);
117
+ expect(buildCounts['demo.home']).toBe(1);
118
+
119
+ const second = await app.request('/v1/screens/demo.home', {
120
+ headers: { Authorization: 'Bearer tok-reuse' },
121
+ });
122
+ expect(second.status).toBe(200);
123
+ expect(buildCounts['demo.home']).toBe(1);
124
+ await expect(second.json()).resolves.toMatchObject({ screen_id: 'demo.home' });
125
+
126
+ const conditional = await app.request('/v1/screens/demo.home', {
127
+ headers: { Authorization: 'Bearer tok-reuse', 'If-None-Match': etag ?? '' },
128
+ });
129
+ expect(conditional.status).toBe(304);
130
+ expect(conditional.headers.get('ETag')).toBe(etag);
131
+ await expect(conditional.text()).resolves.toBe('');
132
+ expect(buildCounts['demo.home']).toBe(1);
133
+ });
134
+
135
+ it('does not reuse the cache when BFF_SCREEN_CACHE_TTL_MS=0', async () => {
136
+ const { app } = createTestApp({ ttlEnv: '0' });
137
+
138
+ await app.request('/v1/screens/demo.home', { headers: { Authorization: 'Bearer tok-0' } });
139
+ await app.request('/v1/screens/demo.home', { headers: { Authorization: 'Bearer tok-0' } });
140
+ expect(buildCounts['demo.home']).toBe(2);
141
+ });
142
+
143
+ it('compresses a screen above the threshold and marks it as varying by encoding', async () => {
144
+ const { app } = createTestApp();
145
+ const response = await app.request('/v1/screens/demo.big', {
146
+ headers: { 'Accept-Encoding': 'gzip' },
147
+ });
148
+
149
+ expect(response.status).toBe(200);
150
+ expect(response.headers.get('Content-Encoding')).toBe('gzip');
151
+ expect(response.headers.get('Vary')).toContain('Accept-Encoding');
152
+
153
+ const bytes = new Uint8Array(await response.arrayBuffer());
154
+ const json = JSON.parse(gunzipSync(bytes).toString('utf8')) as {
155
+ screen_id: string;
156
+ data: { filler: string };
157
+ };
158
+ expect(json.screen_id).toBe('demo.big');
159
+ expect(json.data.filler).toHaveLength('proulr-'.repeat(400).length);
160
+ });
161
+
162
+ it('leaves an identity response uncompressed when the client accepts nothing', async () => {
163
+ const { app } = createTestApp();
164
+ const response = await app.request('/v1/screens/demo.big');
165
+ expect(response.headers.get('Content-Encoding')).toBeNull();
166
+ await expect(response.json()).resolves.toMatchObject({ screen_id: 'demo.big' });
167
+ });
168
+
169
+ it('purges only the declared screen and the author, not the whole park', async () => {
170
+ const actionHandler = vi.fn(async () => ({
171
+ ok: true,
172
+ invalidated_screen_ids: ['demo.big'],
173
+ }));
174
+ const { app } = createTestApp({ actions: { 'social.like': actionHandler } });
175
+
176
+ // autor (tok-author) e outra pessoa (tok-other) com as duas telas em cache
177
+ for (const token of ['tok-author', 'tok-other']) {
178
+ for (const screenId of ['demo.home', 'demo.big']) {
179
+ await app.request(`/v1/screens/${screenId}`, {
180
+ headers: { Authorization: `Bearer ${token}` },
181
+ });
182
+ }
183
+ }
184
+ expect(buildCounts).toEqual({ 'demo.home': 2, 'demo.big': 2 });
185
+
186
+ await app.request('/v1/actions/social.like', {
187
+ method: 'POST',
188
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer tok-author' },
189
+ body: JSON.stringify({ post_id: 'p1' }),
190
+ });
191
+
192
+ // demo.big caiu para todo o parque; demo.home só para o autor.
193
+ await app.request('/v1/screens/demo.big', { headers: { Authorization: 'Bearer tok-other' } });
194
+ expect(buildCounts['demo.big']).toBe(3);
195
+
196
+ await app.request('/v1/screens/demo.home', { headers: { Authorization: 'Bearer tok-other' } });
197
+ expect(buildCounts['demo.home']).toBe(2);
198
+
199
+ await app.request('/v1/screens/demo.home', { headers: { Authorization: 'Bearer tok-author' } });
200
+ expect(buildCounts['demo.home']).toBe(3);
201
+ });
202
+
203
+ it('does not echo the reserved invalidated_screen_ids key in the action response', async () => {
204
+ const { app } = createTestApp({
205
+ actions: {
206
+ 'social.like': async () => ({ ok: true, likes: 3, invalidated_screen_ids: ['demo.home'] }),
207
+ },
208
+ });
209
+ const response = await app.request('/v1/actions/social.like', {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json' },
212
+ body: '{}',
213
+ });
214
+ const body = await response.json();
215
+ expect(body).toMatchObject({ ok: true, action_id: 'social.like', likes: 3 });
216
+ expect(body).not.toHaveProperty('invalidated_screen_ids');
217
+ });
218
+
87
219
  it('ignores non-Bearer Authorization header', async () => {
88
220
  const { app } = createTestApp();
89
221
  const response = await app.request('/v1/screens/demo.home', {
package/src/index.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { serve } from '@hono/node-server';
3
3
  import { Hono } from 'hono';
4
+ import { compress } from 'hono/compress';
4
5
  import { cors } from 'hono/cors';
5
6
  import type { SduiScreenBuilder, SduiScreenBuilderContext } from '@proulr/sdui-contract';
6
7
  import { createBffMetrics, isBffMetricsEnabled } from './bff-metrics';
7
8
  import { ScreenResponseCache, buildScreenCacheKey } from './screen-response-cache';
8
9
 
10
+ /**
11
+ * Handler de action. O retorno é mesclado no JSON de resposta, com uma chave
12
+ * reservada: `invalidated_screen_ids?: string[]` lista as telas cujo cache o BFF
13
+ * sabe alterado por esta escrita (ex.: `feed.social` num like). A chave nunca é
14
+ * devolvida ao cliente.
15
+ */
9
16
  export type SduiActionHandler = (
10
17
  ctx: SduiScreenBuilderContext,
11
18
  body: Record<string, unknown>,
@@ -41,15 +48,32 @@ function resolveScreenId(path: string): string {
41
48
  return 'other';
42
49
  }
43
50
 
51
+ /**
52
+ * Freshness default em vez de 0: com TTL zerado o cache só faz coalescing de
53
+ * in-flight, então cada revalidação do app reconstrói o wire no builder. 5 s é o
54
+ * orçamento em que um like/mensagem atrasa sem o usuário perceber e o round-trip
55
+ * condicional (304) deixa de custar CPU. `BFF_SCREEN_CACHE_TTL_MS=0` continua
56
+ * desligando o cache para quem precisar de leitura fortemente consistente.
57
+ */
58
+ const DEFAULT_SCREEN_CACHE_TTL_MS = 5000;
59
+
60
+ function resolveScreenCacheTtlMs(): number {
61
+ const configured = Number(process.env.BFF_SCREEN_CACHE_TTL_MS);
62
+ return Number.isFinite(configured) && configured >= 0
63
+ ? configured
64
+ : DEFAULT_SCREEN_CACHE_TTL_MS;
65
+ }
66
+
44
67
  export function createSduiBff(options: CreateSduiBffOptions) {
45
68
  const app = new Hono();
46
69
  const metricsEnabled = isBffMetricsEnabled();
47
70
  const metrics = metricsEnabled ? createBffMetrics(options.bffId) : null;
48
- const screenCache = new ScreenResponseCache(
49
- Number(process.env.BFF_SCREEN_CACHE_TTL_MS ?? 0) || 0,
50
- );
71
+ const screenCache = new ScreenResponseCache(resolveScreenCacheTtlMs());
51
72
 
52
73
  app.use('*', cors());
74
+ // O túnel cloudflared vai na porta do BFF sem Caddy no caminho, então a
75
+ // compressão tem que nascer aqui — é o único salto que cruza a internet.
76
+ app.use('*', compress());
53
77
 
54
78
  app.use('*', async (c, next) => {
55
79
  const requestId = randomUUID();
@@ -116,16 +140,22 @@ export function createSduiBff(options: CreateSduiBffOptions) {
116
140
  cacheKey,
117
141
  async () => builder(buildContext({}, query, authToken)),
118
142
  c.req.header('If-None-Match'),
143
+ { screenId, authToken },
119
144
  );
120
145
 
146
+ // O body é sempre identity aqui; hono/compress escolhe o encoding depois. Sem
147
+ // Vary, qualquer borda compartilhada serviria gzip para quem pediu identity.
148
+ const vary = { Vary: 'Accept-Encoding' };
149
+
121
150
  if (hit.notModified) {
122
- return c.newResponse(null, 304, { ETag: hit.entry.etag });
151
+ return c.newResponse(null, 304, { ETag: hit.entry.etag, ...vary });
123
152
  }
124
153
 
125
154
  return c.body(hit.entry.body, 200, {
126
155
  'Content-Type': 'application/json; charset=utf-8',
127
156
  ETag: hit.entry.etag,
128
157
  'Cache-Control': 'private, no-cache',
158
+ ...vary,
129
159
  });
130
160
  });
131
161
 
@@ -146,8 +176,16 @@ export function createSduiBff(options: CreateSduiBffOptions) {
146
176
  try {
147
177
  const authToken = readAuthToken(c.req.header('Authorization'));
148
178
  const result = await handler(buildContext({}, {}, authToken), body);
149
- screenCache.purgeAll();
150
- return c.json({ ok: true, action_id: actionId, ...result });
179
+ // `invalidated_screen_ids` é a tela que o BFF dono sabe ter mudado. Sem
180
+ // lista, só o token do autor é purgado — `purgeAll()` derrubava o cache de
181
+ // todas as telas de todo o parque a cada like.
182
+ const { invalidated_screen_ids: invalidatedScreenIds, ...payload } = result;
183
+ const screens = Array.isArray(invalidatedScreenIds)
184
+ ? invalidatedScreenIds.filter((screenId): screenId is string => typeof screenId === 'string')
185
+ : [];
186
+ screenCache.invalidateScreens(screens);
187
+ screenCache.invalidateToken(authToken);
188
+ return c.json({ ok: true, action_id: actionId, ...payload });
151
189
  } catch (error) {
152
190
  console.warn(`[${options.bffId}] action ${actionId} failed`, error);
153
191
  return c.json(
@@ -178,8 +216,11 @@ export {
178
216
  ScreenResponseCache,
179
217
  buildScreenCacheKey,
180
218
  computeScreenEtag,
219
+ computeScreenRevision,
181
220
  type ScreenCacheEntry,
182
221
  type ScreenCacheHit,
222
+ type ScreenCacheKeyInput,
223
+ type ScreenCacheScopeInput,
183
224
  } from './screen-response-cache';
184
225
  export {
185
226
  buildNativeAdInsertionSeed,
@@ -3,8 +3,13 @@ import {
3
3
  ScreenResponseCache,
4
4
  buildScreenCacheKey,
5
5
  computeScreenEtag,
6
+ computeScreenRevision,
6
7
  } from './screen-response-cache';
7
8
 
9
+ function parseBody(body: string): Record<string, unknown> {
10
+ return JSON.parse(body) as Record<string, unknown>;
11
+ }
12
+
8
13
  describe('buildScreenCacheKey', () => {
9
14
  it('is stable regardless of query insertion order', () => {
10
15
  const a = buildScreenCacheKey({
@@ -63,8 +68,41 @@ describe('ScreenResponseCache', () => {
63
68
 
64
69
  const [h1, h2] = await Promise.all([p1, p2]);
65
70
  expect(calls).toBe(1);
66
- expect(h1.entry.body).toBe('{"ok":true}');
67
- expect(h2.entry.body).toBe('{"ok":true}');
71
+ expect(h1.entry.body).toContain('"ok":true');
72
+ expect(h2.entry.body).toBe(h1.entry.body);
73
+ });
74
+
75
+ it('stamps meta.rev with the same digest as the weak ETag', async () => {
76
+ const cache = new ScreenResponseCache(5_000);
77
+ const wire = { schema_version: 1, screen_id: 'feed.social', blocks: [] };
78
+ const hit = await cache.get('k', async () => wire);
79
+
80
+ const rev = (parseBody(hit.entry.body).meta as { rev: string }).rev;
81
+ expect(hit.entry.etag).toBe(`W/"${rev}"`);
82
+ // O digest é do wire antes da injeção — o corpo final não é a fonte do rev.
83
+ expect(rev).toBe(computeScreenRevision(JSON.stringify(wire)));
84
+ });
85
+
86
+ it('keeps the rev stable for identical content and rotates it when the wire changes', async () => {
87
+ const cache = new ScreenResponseCache(0);
88
+ let n = 0;
89
+ const build = async () => ({ screen_id: 'feed.social', n: ++n });
90
+
91
+ const first = await cache.get('k', build);
92
+ const second = await cache.get('k', build);
93
+ const firstRev = (parseBody(first.entry.body).meta as { rev: string }).rev;
94
+ const secondRev = (parseBody(second.entry.body).meta as { rev: string }).rev;
95
+ expect(secondRev).not.toBe(firstRev);
96
+
97
+ const twin = await cache.get('k2', async () => ({ screen_id: 'feed.social', n: 2 }));
98
+ expect((parseBody(twin.entry.body).meta as { rev: string }).rev).toBe(secondRev);
99
+ });
100
+
101
+ it('leaves a non-object payload untouched', async () => {
102
+ const cache = new ScreenResponseCache(5_000);
103
+ const hit = await cache.get('k', async () => 'plain');
104
+ expect(hit.entry.body).toBe('"plain"');
105
+ expect(hit.entry.etag).toBe(computeScreenEtag('"plain"'));
68
106
  });
69
107
 
70
108
  it('rebuilds after completion when ttl is 0', async () => {
@@ -75,7 +113,7 @@ describe('ScreenResponseCache', () => {
75
113
  await cache.get('k', build);
76
114
  const second = await cache.get('k', build);
77
115
  expect(calls).toBe(2);
78
- expect(second.entry.body).toBe('{"n":2}');
116
+ expect(parseBody(second.entry.body).n).toBe(2);
79
117
  });
80
118
 
81
119
  it('serves fresh entries within ttl and flags matching etag as not modified', async () => {
@@ -121,6 +159,78 @@ describe('ScreenResponseCache', () => {
121
159
  expect(calls).toBe(2);
122
160
  });
123
161
 
162
+ it('invalidateToken drops only that user entries', async () => {
163
+ let calls = 0;
164
+ const build = async () => ({ n: ++calls });
165
+ const cache = new ScreenResponseCache(5_000);
166
+
167
+ const authorKey = buildScreenCacheKey({ screenId: 'feed.social', query: {}, authToken: 'A' });
168
+ const otherKey = buildScreenCacheKey({ screenId: 'feed.social', query: {}, authToken: 'B' });
169
+ await cache.get(authorKey, build, null, { screenId: 'feed.social', authToken: 'A' });
170
+ await cache.get(otherKey, build, null, { screenId: 'feed.social', authToken: 'B' });
171
+ expect(calls).toBe(2);
172
+
173
+ cache.invalidateToken('A');
174
+
175
+ // O autor reconstrói; a entrada do outro usuário continua no cache.
176
+ await cache.get(authorKey, build, null, { screenId: 'feed.social', authToken: 'A' });
177
+ expect(calls).toBe(3);
178
+ await cache.get(otherKey, build, null, { screenId: 'feed.social', authToken: 'B' });
179
+ expect(calls).toBe(3);
180
+ });
181
+
182
+ it('invalidateToken also clears the anonymous scope', async () => {
183
+ let calls = 0;
184
+ const build = async () => ({ n: ++calls });
185
+ const cache = new ScreenResponseCache(5_000);
186
+ const key = buildScreenCacheKey({ screenId: 'vibes.home', query: {} });
187
+
188
+ await cache.get(key, build, null, { screenId: 'vibes.home' });
189
+ cache.invalidateToken(undefined);
190
+ await cache.get(key, build, null, { screenId: 'vibes.home' });
191
+ expect(calls).toBe(2);
192
+ });
193
+
194
+ it('invalidateScreens drops the screen for every token and ignores an empty list', async () => {
195
+ let calls = 0;
196
+ const build = async () => ({ n: ++calls });
197
+ const cache = new ScreenResponseCache(5_000);
198
+ const keyA = buildScreenCacheKey({ screenId: 'feed.social', query: {}, authToken: 'A' });
199
+ const keyB = buildScreenCacheKey({ screenId: 'feed.social', query: {}, authToken: 'B' });
200
+ const otherKey = buildScreenCacheKey({ screenId: 'chat.inbox', query: {}, authToken: 'A' });
201
+
202
+ await cache.get(keyA, build, null, { screenId: 'feed.social', authToken: 'A' });
203
+ await cache.get(keyB, build, null, { screenId: 'feed.social', authToken: 'B' });
204
+ await cache.get(otherKey, build, null, { screenId: 'chat.inbox', authToken: 'A' });
205
+ expect(calls).toBe(3);
206
+
207
+ cache.invalidateScreens([]);
208
+ await cache.get(keyA, build, null, { screenId: 'feed.social', authToken: 'A' });
209
+ expect(calls).toBe(3);
210
+
211
+ cache.invalidateScreens(['feed.social']);
212
+ await cache.get(keyA, build, null, { screenId: 'feed.social', authToken: 'A' });
213
+ await cache.get(keyB, build, null, { screenId: 'feed.social', authToken: 'B' });
214
+ expect(calls).toBe(5);
215
+ await cache.get(otherKey, build, null, { screenId: 'chat.inbox', authToken: 'A' });
216
+ expect(calls).toBe(5);
217
+ });
218
+
219
+ it('forgotten scopes evicted with their entries do not break invalidation', async () => {
220
+ let calls = 0;
221
+ const build = async () => ({ n: ++calls });
222
+ const cache = new ScreenResponseCache(5_000, 2);
223
+
224
+ await cache.get('a', build, null, { screenId: 'feed.social', authToken: 'A' });
225
+ await cache.get('b', build, null, { screenId: 'chat.inbox', authToken: 'B' });
226
+ await cache.get('c', build, null, { screenId: 'vibes.home', authToken: 'C' });
227
+
228
+ expect(() => {
229
+ cache.invalidateScreens(['feed.social']);
230
+ cache.invalidateToken('A');
231
+ }).not.toThrow();
232
+ });
233
+
124
234
  it('does not cache failures', async () => {
125
235
  let calls = 0;
126
236
  const build = async () => {
@@ -133,7 +243,7 @@ describe('ScreenResponseCache', () => {
133
243
  await expect(cache.get('k', build)).rejects.toThrow('boom');
134
244
  const ok = await cache.get('k', build);
135
245
  expect(calls).toBe(2);
136
- expect(ok.entry.body).toBe('{"ok":true}');
246
+ expect(parseBody(ok.entry.body).ok).toBe(true);
137
247
  });
138
248
 
139
249
  it('evicts the oldest entries beyond the limit', async () => {
@@ -18,6 +18,24 @@ export type ScreenCacheHit = {
18
18
  notModified: boolean;
19
19
  };
20
20
 
21
+ /**
22
+ * Domínio de uma entrada, lembrado junto dela para permitir purge sem reconstruir
23
+ * a chave. O token nunca fica retido cru — só o escopo derivado dele.
24
+ */
25
+ type ScreenCacheScope = {
26
+ screenId: string;
27
+ tokenScope: string;
28
+ };
29
+
30
+ export type ScreenCacheScopeInput = {
31
+ screenId: string;
32
+ authToken?: string;
33
+ };
34
+
35
+ function hashTokenScope(authToken?: string): string {
36
+ return createHash('sha256').update(authToken ?? 'anon').digest('base64url');
37
+ }
38
+
21
39
  export function buildScreenCacheKey(input: ScreenCacheKeyInput): string {
22
40
  const queryPart = Object.keys(input.query)
23
41
  .sort()
@@ -33,18 +51,44 @@ export function buildScreenCacheKey(input: ScreenCacheKeyInput): string {
33
51
  }
34
52
 
35
53
  export function computeScreenEtag(body: string): string {
36
- return `W/"${createHash('sha256').update(body).digest('base64url').slice(0, 32)}"`;
54
+ return `W/"${computeScreenRevision(body)}"`;
55
+ }
56
+
57
+ /**
58
+ * Digest do wire que vira duas coisas do mesmo valor: o ETag fraco da resposta e
59
+ * `meta.rev` dentro do corpo. O app precisa do segundo porque o wire persistido
60
+ * em AsyncStorage não carrega header HTTP — sem `rev` ele só saberia se mudou
61
+ * baixando o corpo inteiro.
62
+ */
63
+ export function computeScreenRevision(body: string): string {
64
+ return createHash('sha256').update(body).digest('base64url').slice(0, 32);
65
+ }
66
+
67
+ /**
68
+ * O digest é do wire **sem** `meta.rev`, senão ele dependeria de si mesmo. Por
69
+ * isso o hash é feito sobre a serialização original e a chave é injetada depois;
70
+ * o corpo em cache e o ETag apontam para o mesmo digest.
71
+ */
72
+ function withRevision(screen: unknown, rev: string): unknown {
73
+ if (!screen || typeof screen !== 'object' || Array.isArray(screen)) return screen;
74
+ const meta = (screen as { meta?: unknown }).meta;
75
+ const metaObject = meta && typeof meta === 'object' && !Array.isArray(meta)
76
+ ? (meta as Record<string, unknown>)
77
+ : {};
78
+ return { ...screen, meta: { ...metaObject, rev } };
37
79
  }
38
80
 
39
81
  /**
40
82
  * Dedupes identical in-flight screen builds (the mount/focus revalidation storm
41
- * fires the same GET 3-4x concurrently). `ttlMs > 0` additionally serves
42
- * completed results while fresh; keep it low (default 0) so direct client-side
43
- * writes stay near-real-time.
83
+ * fires the same GET 3-4x concurrently) and, with `ttlMs > 0`, serves completed
84
+ * results while fresh. Freshness is bounded by TTL and by the targeted purges
85
+ * (`invalidateScreens` / `invalidateToken`), never by dropping the whole park's
86
+ * cache on every write — an action used to invalidate every screen for everyone.
44
87
  */
45
88
  export class ScreenResponseCache {
46
89
  private entries = new Map<string, ScreenCacheEntry>();
47
90
  private pending = new Map<string, Promise<ScreenCacheEntry>>();
91
+ private scopes = new Map<string, ScreenCacheScope>();
48
92
 
49
93
  constructor(
50
94
  private readonly ttlMs: number,
@@ -56,6 +100,7 @@ export class ScreenResponseCache {
56
100
  key: string,
57
101
  build: () => Promise<unknown>,
58
102
  ifNoneMatch?: string | null,
103
+ scope?: ScreenCacheScopeInput,
59
104
  ): Promise<ScreenCacheHit> {
60
105
  const fresh = this.entries.get(key);
61
106
  if (fresh && fresh.freshUntil > this.now()) {
@@ -71,7 +116,7 @@ export class ScreenResponseCache {
71
116
  }));
72
117
  }
73
118
 
74
- const promise = this.buildEntry(key, build);
119
+ const promise = this.buildEntry(key, build, scope);
75
120
  this.pending.set(key, promise);
76
121
  return promise.then((entry) => ({
77
122
  entry,
@@ -80,23 +125,56 @@ export class ScreenResponseCache {
80
125
  }));
81
126
  }
82
127
 
128
+ /** Purge de uma tela em todos os queries/tokens — o que uma action declara ter mudado. */
129
+ invalidateScreens(screenIds: readonly string[]): void {
130
+ if (screenIds.length === 0) return;
131
+ const wanted = new Set(screenIds);
132
+ this.dropWhere((scope) => wanted.has(scope.screenId));
133
+ }
134
+
135
+ /**
136
+ * Purge do autor da action: quem escreve nunca relê o próprio estado atrasado,
137
+ * e as telas dos outros usuários continuam pagando só o TTL.
138
+ */
139
+ invalidateToken(authToken?: string): void {
140
+ const tokenScope = hashTokenScope(authToken);
141
+ this.dropWhere((scope) => scope.tokenScope === tokenScope);
142
+ }
143
+
83
144
  purgeAll(): void {
84
145
  this.entries.clear();
146
+ this.scopes.clear();
147
+ }
148
+
149
+ private dropWhere(predicate: (scope: ScreenCacheScope) => boolean): void {
150
+ for (const [key, scope] of this.scopes) {
151
+ if (!predicate(scope)) continue;
152
+ this.entries.delete(key);
153
+ this.scopes.delete(key);
154
+ }
85
155
  }
86
156
 
87
157
  private async buildEntry(
88
158
  key: string,
89
159
  build: () => Promise<unknown>,
160
+ scope?: ScreenCacheScopeInput,
90
161
  ): Promise<ScreenCacheEntry> {
91
162
  try {
92
163
  const screen = await build();
93
- const body = JSON.stringify(screen);
164
+ const rev = computeScreenRevision(JSON.stringify(screen));
165
+ const body = JSON.stringify(withRevision(screen, rev));
94
166
  const entry: ScreenCacheEntry = {
95
167
  body,
96
- etag: computeScreenEtag(body),
168
+ etag: `W/"${rev}"`,
97
169
  freshUntil: this.now() + this.ttlMs,
98
170
  };
99
171
  this.entries.set(key, entry);
172
+ if (scope) {
173
+ this.scopes.set(key, {
174
+ screenId: scope.screenId,
175
+ tokenScope: hashTokenScope(scope.authToken),
176
+ });
177
+ }
100
178
  this.evictOverflow();
101
179
  return entry;
102
180
  } finally {
@@ -109,6 +187,7 @@ export class ScreenResponseCache {
109
187
  const oldest = this.entries.keys().next().value;
110
188
  if (oldest === undefined) break;
111
189
  this.entries.delete(oldest);
190
+ this.scopes.delete(oldest);
112
191
  }
113
192
  }
114
193
  }