cursedbelt-server 4.46.0 → 4.47.0
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/dist/server/storage/binaryServerMeta.d.ts +42 -0
- package/dist/server/storage/binaryServerMeta.js +57 -0
- package/dist/server/storage/binaryStore.d.ts +2 -0
- package/dist/server/storage/binaryStore.js +10 -5
- package/package.json +1 -1
- package/src/leafSubpathsImportNothing.spec.ts +2 -0
- package/src/server/storage/binaryServerMeta.spec.ts +52 -0
- package/src/server/storage/binaryServerMeta.ts +81 -0
- package/src/server/storage/binaryStore.spec.ts +24 -1
- package/src/server/storage/binaryStore.ts +10 -5
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What binary-server holds under MANY keys — `POST /internal/ops/meta`, one request per
|
|
3
|
+
* {@link BINARY_META_KEYS_MAX} keys (forge task 3385).
|
|
4
|
+
*
|
|
5
|
+
* 🔴 One client, because two hand-rolled copies drifted into being: collections' media-cipher
|
|
6
|
+
* sweep (forge task 2634 — ~880 per-key `/meta` asks a run against its 8/s bulk ceiling, ~2,300
|
|
7
|
+
* refusals an hour) and music's nightly missing-bytes check (forge task 3382) each carried the
|
|
8
|
+
* loop and a copied `1_000`. A change to the route's cap or shape is now one edit here, and
|
|
9
|
+
* binary-server takes its own cap from {@link BINARY_META_KEYS_MAX}, so the two cannot disagree.
|
|
10
|
+
*
|
|
11
|
+
* The route is internal-secret gated and loopback-only (`/internal/ops/*` 404s on the public
|
|
12
|
+
* hostname), so `baseUrl` is binary-server's INTERNAL origin, never the tunnel's.
|
|
13
|
+
*/
|
|
14
|
+
/** Keys per `POST /internal/ops/meta` — binary-server's `META_KEYS_MAX` is this constant. */
|
|
15
|
+
export declare const BINARY_META_KEYS_MAX = 1000;
|
|
16
|
+
/** One key's answer: an indexed, active row whose bytes exist. */
|
|
17
|
+
export interface StoredMeta {
|
|
18
|
+
size: number;
|
|
19
|
+
mime: string | null;
|
|
20
|
+
checksum: string | null;
|
|
21
|
+
}
|
|
22
|
+
export interface MetaManyTarget {
|
|
23
|
+
/** binary-server's internal origin (`BINARY_SERVER_INTERNAL_URL`). */
|
|
24
|
+
baseUrl: string | undefined;
|
|
25
|
+
internalSecret: string | undefined;
|
|
26
|
+
/** The tenant the keys belong to. */
|
|
27
|
+
app: string;
|
|
28
|
+
}
|
|
29
|
+
export interface MetaManyOptions {
|
|
30
|
+
fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
|
|
31
|
+
/** Per batch. Default {@link BINARY_META_BATCH_TIMEOUT_MS}. */
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Every asked key → its {@link StoredMeta}, or `null` when the store holds nothing live there.
|
|
36
|
+
*
|
|
37
|
+
* The whole answer is `null` on ANY failure — no origin or secret, a refusal (an older
|
|
38
|
+
* binary-server without the route answers 404; a batch over its cap, 400), a timeout, a body of
|
|
39
|
+
* another shape. Callers then ask key by key, as before: slower, never wrong. A partial map is
|
|
40
|
+
* never returned, so "absent from the map" cannot be mistaken for "not held".
|
|
41
|
+
*/
|
|
42
|
+
export declare function metaMany(to: MetaManyTarget, keys: readonly string[], opts?: MetaManyOptions): Promise<Map<string, StoredMeta | null> | null>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What binary-server holds under MANY keys — `POST /internal/ops/meta`, one request per
|
|
3
|
+
* {@link BINARY_META_KEYS_MAX} keys (forge task 3385).
|
|
4
|
+
*
|
|
5
|
+
* 🔴 One client, because two hand-rolled copies drifted into being: collections' media-cipher
|
|
6
|
+
* sweep (forge task 2634 — ~880 per-key `/meta` asks a run against its 8/s bulk ceiling, ~2,300
|
|
7
|
+
* refusals an hour) and music's nightly missing-bytes check (forge task 3382) each carried the
|
|
8
|
+
* loop and a copied `1_000`. A change to the route's cap or shape is now one edit here, and
|
|
9
|
+
* binary-server takes its own cap from {@link BINARY_META_KEYS_MAX}, so the two cannot disagree.
|
|
10
|
+
*
|
|
11
|
+
* The route is internal-secret gated and loopback-only (`/internal/ops/*` 404s on the public
|
|
12
|
+
* hostname), so `baseUrl` is binary-server's INTERNAL origin, never the tunnel's.
|
|
13
|
+
*/
|
|
14
|
+
/** Keys per `POST /internal/ops/meta` — binary-server's `META_KEYS_MAX` is this constant. */
|
|
15
|
+
export const BINARY_META_KEYS_MAX = 1_000;
|
|
16
|
+
/** How long one batch may take: a batch of keys the store's memo lacks is HEADed eight at a time. */
|
|
17
|
+
const BINARY_META_BATCH_TIMEOUT_MS = 180_000;
|
|
18
|
+
/**
|
|
19
|
+
* Every asked key → its {@link StoredMeta}, or `null` when the store holds nothing live there.
|
|
20
|
+
*
|
|
21
|
+
* The whole answer is `null` on ANY failure — no origin or secret, a refusal (an older
|
|
22
|
+
* binary-server without the route answers 404; a batch over its cap, 400), a timeout, a body of
|
|
23
|
+
* another shape. Callers then ask key by key, as before: slower, never wrong. A partial map is
|
|
24
|
+
* never returned, so "absent from the map" cannot be mistaken for "not held".
|
|
25
|
+
*/
|
|
26
|
+
export async function metaMany(to, keys, opts = {}) {
|
|
27
|
+
if (!to.baseUrl || !to.internalSecret)
|
|
28
|
+
return null;
|
|
29
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
30
|
+
const url = `${to.baseUrl.replace(/\/+$/, '')}/internal/ops/meta`;
|
|
31
|
+
const out = new Map();
|
|
32
|
+
try {
|
|
33
|
+
for (let i = 0; i < keys.length; i += BINARY_META_KEYS_MAX) {
|
|
34
|
+
const batch = keys.slice(i, i + BINARY_META_KEYS_MAX);
|
|
35
|
+
const res = await doFetch(url, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'content-type': 'application/json', 'x-internal-secret': to.internalSecret },
|
|
38
|
+
body: JSON.stringify({ app: to.app, keys: batch }),
|
|
39
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? BINARY_META_BATCH_TIMEOUT_MS),
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok)
|
|
42
|
+
return null;
|
|
43
|
+
const body = (await res.json());
|
|
44
|
+
if (!body?.metas || typeof body.metas !== 'object')
|
|
45
|
+
return null;
|
|
46
|
+
const metas = body.metas;
|
|
47
|
+
for (const key of batch) {
|
|
48
|
+
const m = metas[key];
|
|
49
|
+
out.set(key, m && typeof m.size === 'number' ? { size: m.size, mime: m.mime ?? null, checksum: m.checksum ?? null } : null);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
@@ -6,6 +6,8 @@ import type { FileTokenClaims } from './types.js';
|
|
|
6
6
|
* this module read unchanged.
|
|
7
7
|
*/
|
|
8
8
|
export type { FileTokenClaims };
|
|
9
|
+
/** The batched `/internal/ops/meta` client — its own module so the store's graph stays the store's. */
|
|
10
|
+
export { BINARY_META_KEYS_MAX, metaMany, type StoredMeta } from './binaryServerMeta.js';
|
|
9
11
|
export interface BinaryStoreConfig {
|
|
10
12
|
/** e.g. `http://127.0.0.1:3099` or `https://binary-server.cursedalchemy.com`. */
|
|
11
13
|
baseUrl: string;
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
* behavioural difference in any of the three, so nothing was dropped to land the union.
|
|
42
42
|
*/
|
|
43
43
|
import { createHash, createPrivateKey, createPublicKey, sign as cryptoSign } from 'node:crypto';
|
|
44
|
+
/** The batched `/internal/ops/meta` client — its own module so the store's graph stays the store's. */
|
|
45
|
+
export { BINARY_META_KEYS_MAX, metaMany } from './binaryServerMeta.js';
|
|
44
46
|
/*
|
|
45
47
|
* ── The process-wide repeat register (§ the repeat-key counter) ───────────────
|
|
46
48
|
*
|
|
@@ -417,13 +419,16 @@ export function createBinaryStore(cfg) {
|
|
|
417
419
|
* caller asked for `paced` and the budget allows, otherwise thrown as a typed error that
|
|
418
420
|
* keeps the `[binary-store] <op> <key>: 429` message batch callers already match.
|
|
419
421
|
*/
|
|
420
|
-
const sendRead = async (op, key,
|
|
422
|
+
const sendRead = async (op, key,
|
|
423
|
+
/** Signs the URL. Called for EVERY attempt: a read token lives 60 s and a paced read may wait
|
|
424
|
+
* 120 s, so a URL signed once went out dead after the first long retry-after (forge task 3302). */
|
|
425
|
+
mint, init, opts) => {
|
|
421
426
|
const budget = opts?.paced
|
|
422
427
|
? (typeof opts.paced === 'object' ? opts.paced.maxWaitMs : undefined) ?? BINARY_PACED_MAX_WAIT_MS
|
|
423
428
|
: 0;
|
|
424
429
|
let waited = 0;
|
|
425
430
|
for (let attempt = 0;; attempt++) {
|
|
426
|
-
const res = await send(
|
|
431
|
+
const res = await send(await mint(), init);
|
|
427
432
|
if (res.status !== 429)
|
|
428
433
|
return res;
|
|
429
434
|
const retryAfterSec = retryAfterSeconds(res.headers.get('retry-after'), attempt);
|
|
@@ -548,7 +553,7 @@ export function createBinaryStore(cfg) {
|
|
|
548
553
|
if (!opts?.fresh && knownAbsent(key))
|
|
549
554
|
return null;
|
|
550
555
|
countWireRead(key);
|
|
551
|
-
const res = await sendRead('get', key,
|
|
556
|
+
const res = await sendRead('get', key, () => internalMediaUrl(key), undefined, opts);
|
|
552
557
|
if (res.status === 404) {
|
|
553
558
|
rememberAbsent(key);
|
|
554
559
|
return null;
|
|
@@ -624,7 +629,7 @@ export function createBinaryStore(cfg) {
|
|
|
624
629
|
return null;
|
|
625
630
|
}
|
|
626
631
|
countWireRead(key);
|
|
627
|
-
const res = await sendRead('meta', key,
|
|
632
|
+
const res = await sendRead('meta', key, () => metaUrl(key), undefined, opts);
|
|
628
633
|
if (res.status === 404) {
|
|
629
634
|
rememberAbsent(key);
|
|
630
635
|
return null;
|
|
@@ -671,7 +676,7 @@ export function createBinaryStore(cfg) {
|
|
|
671
676
|
throw why;
|
|
672
677
|
}
|
|
673
678
|
countWireRead(key);
|
|
674
|
-
const res = await sendRead('stat', key,
|
|
679
|
+
const res = await sendRead('stat', key, () => internalMediaUrl(key), { headers: { Range: 'bytes=0-0' } }, opts);
|
|
675
680
|
if (res.status === 404) {
|
|
676
681
|
rememberAbsent(key);
|
|
677
682
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.47.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -144,8 +144,10 @@ const LEAVES = [
|
|
|
144
144
|
* in `src/server/storage/` beside the file-catalogue and transcode graph, which is
|
|
145
145
|
* exactly the barrel this must never be reached through: `./storage`'s index is heavy
|
|
146
146
|
* and this module's whole point is that it costs `node:crypto` and nothing else.
|
|
147
|
+
* Its batched `/internal/ops/meta` client (4.47.0) is a sibling that imports nothing.
|
|
147
148
|
*/
|
|
148
149
|
evaluates: 'createBinaryStore',
|
|
150
|
+
siblings: ['src/server/storage/binaryServerMeta.ts'],
|
|
149
151
|
},
|
|
150
152
|
{
|
|
151
153
|
subpath: './binary-store/testing',
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { BINARY_META_KEYS_MAX, metaMany } from './binaryStore.js';
|
|
3
|
+
|
|
4
|
+
type Call = { url: string; init: RequestInit };
|
|
5
|
+
const target = { baseUrl: 'http://127.0.0.1:3099/', internalSecret: 's3cret', app: 'music' };
|
|
6
|
+
|
|
7
|
+
/** A store that holds every key whose name starts with `held/`, answering in the route's shape. */
|
|
8
|
+
function store(calls: Call[], answer?: (keys: string[]) => Response) {
|
|
9
|
+
return async (url: string, init: RequestInit) => {
|
|
10
|
+
calls.push({ url, init });
|
|
11
|
+
const { keys } = JSON.parse(String(init.body)) as { keys: string[] };
|
|
12
|
+
if (answer) return answer(keys);
|
|
13
|
+
const metas = Object.fromEntries(keys.map((k) => [k, k.startsWith('held/') ? { size: 7, mime: 'audio/mp4', checksum: 'abc' } : null]));
|
|
14
|
+
return Response.json({ app: 'music', metas });
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('metaMany', () => {
|
|
19
|
+
it('asks one request per BINARY_META_KEYS_MAX keys, and answers every key', async () => {
|
|
20
|
+
const calls: Call[] = [];
|
|
21
|
+
const keys = Array.from({ length: BINARY_META_KEYS_MAX * 2 + 1 }, (_, i) => (i % 2 ? `held/${i}` : `gone/${i}`));
|
|
22
|
+
const got = await metaMany(target, keys, { fetchImpl: store(calls) });
|
|
23
|
+
expect(calls.map((c) => (JSON.parse(String(c.init.body)) as { keys: string[] }).keys.length)).toEqual([BINARY_META_KEYS_MAX, BINARY_META_KEYS_MAX, 1]);
|
|
24
|
+
expect(calls[0]?.url).toBe('http://127.0.0.1:3099/internal/ops/meta');
|
|
25
|
+
expect(calls[0]?.init.headers).toMatchObject({ 'x-internal-secret': 's3cret' });
|
|
26
|
+
expect(got?.size).toBe(keys.length);
|
|
27
|
+
expect(got?.get('held/1')).toEqual({ size: 7, mime: 'audio/mp4', checksum: 'abc' });
|
|
28
|
+
expect(got?.get('gone/0')).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('is null — never a partial map — when any batch fails', async () => {
|
|
32
|
+
let n = 0;
|
|
33
|
+
const keys = Array.from({ length: BINARY_META_KEYS_MAX + 1 }, (_, i) => `held/${i}`);
|
|
34
|
+
const second404 = store([], (asked) => (n++ === 0 ? Response.json({ metas: Object.fromEntries(asked.map((k) => [k, null])) }) : new Response('nope', { status: 404 })));
|
|
35
|
+
expect(await metaMany(target, keys, { fetchImpl: second404 })).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('is null on a body of another shape, a throw, or no origin/secret — and asks nothing then', async () => {
|
|
39
|
+
expect(await metaMany(target, ['k'], { fetchImpl: store([], () => Response.json({ ok: true })) })).toBeNull();
|
|
40
|
+
expect(await metaMany(target, ['k'], { fetchImpl: async () => { throw new Error('ECONNREFUSED'); } })).toBeNull();
|
|
41
|
+
const calls: Call[] = [];
|
|
42
|
+
expect(await metaMany({ ...target, internalSecret: undefined }, ['k'], { fetchImpl: store(calls) })).toBeNull();
|
|
43
|
+
expect(await metaMany({ ...target, baseUrl: '' }, ['k'], { fetchImpl: store(calls) })).toBeNull();
|
|
44
|
+
expect(calls).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('reads a key the store left out of its answer as not held', async () => {
|
|
48
|
+
const got = await metaMany(target, ['held/a', 'held/b'], { fetchImpl: store([], () => Response.json({ metas: { 'held/a': { size: 1 } } })) });
|
|
49
|
+
expect(got?.get('held/a')).toEqual({ size: 1, mime: null, checksum: null });
|
|
50
|
+
expect(got?.get('held/b')).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What binary-server holds under MANY keys — `POST /internal/ops/meta`, one request per
|
|
3
|
+
* {@link BINARY_META_KEYS_MAX} keys (forge task 3385).
|
|
4
|
+
*
|
|
5
|
+
* 🔴 One client, because two hand-rolled copies drifted into being: collections' media-cipher
|
|
6
|
+
* sweep (forge task 2634 — ~880 per-key `/meta` asks a run against its 8/s bulk ceiling, ~2,300
|
|
7
|
+
* refusals an hour) and music's nightly missing-bytes check (forge task 3382) each carried the
|
|
8
|
+
* loop and a copied `1_000`. A change to the route's cap or shape is now one edit here, and
|
|
9
|
+
* binary-server takes its own cap from {@link BINARY_META_KEYS_MAX}, so the two cannot disagree.
|
|
10
|
+
*
|
|
11
|
+
* The route is internal-secret gated and loopback-only (`/internal/ops/*` 404s on the public
|
|
12
|
+
* hostname), so `baseUrl` is binary-server's INTERNAL origin, never the tunnel's.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Keys per `POST /internal/ops/meta` — binary-server's `META_KEYS_MAX` is this constant. */
|
|
16
|
+
export const BINARY_META_KEYS_MAX = 1_000;
|
|
17
|
+
|
|
18
|
+
/** How long one batch may take: a batch of keys the store's memo lacks is HEADed eight at a time. */
|
|
19
|
+
const BINARY_META_BATCH_TIMEOUT_MS = 180_000;
|
|
20
|
+
|
|
21
|
+
/** One key's answer: an indexed, active row whose bytes exist. */
|
|
22
|
+
export interface StoredMeta {
|
|
23
|
+
size: number;
|
|
24
|
+
mime: string | null;
|
|
25
|
+
checksum: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MetaManyTarget {
|
|
29
|
+
/** binary-server's internal origin (`BINARY_SERVER_INTERNAL_URL`). */
|
|
30
|
+
baseUrl: string | undefined;
|
|
31
|
+
internalSecret: string | undefined;
|
|
32
|
+
/** The tenant the keys belong to. */
|
|
33
|
+
app: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MetaManyOptions {
|
|
37
|
+
fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
|
|
38
|
+
/** Per batch. Default {@link BINARY_META_BATCH_TIMEOUT_MS}. */
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every asked key → its {@link StoredMeta}, or `null` when the store holds nothing live there.
|
|
44
|
+
*
|
|
45
|
+
* The whole answer is `null` on ANY failure — no origin or secret, a refusal (an older
|
|
46
|
+
* binary-server without the route answers 404; a batch over its cap, 400), a timeout, a body of
|
|
47
|
+
* another shape. Callers then ask key by key, as before: slower, never wrong. A partial map is
|
|
48
|
+
* never returned, so "absent from the map" cannot be mistaken for "not held".
|
|
49
|
+
*/
|
|
50
|
+
export async function metaMany(
|
|
51
|
+
to: MetaManyTarget,
|
|
52
|
+
keys: readonly string[],
|
|
53
|
+
opts: MetaManyOptions = {},
|
|
54
|
+
): Promise<Map<string, StoredMeta | null> | null> {
|
|
55
|
+
if (!to.baseUrl || !to.internalSecret) return null;
|
|
56
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
57
|
+
const url = `${to.baseUrl.replace(/\/+$/, '')}/internal/ops/meta`;
|
|
58
|
+
const out = new Map<string, StoredMeta | null>();
|
|
59
|
+
try {
|
|
60
|
+
for (let i = 0; i < keys.length; i += BINARY_META_KEYS_MAX) {
|
|
61
|
+
const batch = keys.slice(i, i + BINARY_META_KEYS_MAX);
|
|
62
|
+
const res = await doFetch(url, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { 'content-type': 'application/json', 'x-internal-secret': to.internalSecret },
|
|
65
|
+
body: JSON.stringify({ app: to.app, keys: batch }),
|
|
66
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? BINARY_META_BATCH_TIMEOUT_MS),
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) return null;
|
|
69
|
+
const body = (await res.json()) as { metas?: unknown } | null;
|
|
70
|
+
if (!body?.metas || typeof body.metas !== 'object') return null;
|
|
71
|
+
const metas = body.metas as Record<string, Partial<StoredMeta> | null | undefined>;
|
|
72
|
+
for (const key of batch) {
|
|
73
|
+
const m = metas[key];
|
|
74
|
+
out.set(key, m && typeof m.size === 'number' ? { size: m.size, mime: m.mime ?? null, checksum: m.checksum ?? null } : null);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* server; the RSA keypair is generated in-process and the signature is verified with
|
|
27
27
|
* `node:crypto`, which is what makes the token assertions real rather than shape-deep.
|
|
28
28
|
*/
|
|
29
|
-
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
29
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it, setSystemTime } from 'bun:test';
|
|
30
30
|
import { generateKeyPairSync, verify as cryptoVerify } from 'node:crypto';
|
|
31
31
|
import {
|
|
32
32
|
AUTO_CHUNK_THRESHOLD_BYTES,
|
|
@@ -1056,6 +1056,29 @@ describe('🔴 a read the store PACES is waited out when asked, typed when not (
|
|
|
1056
1056
|
expect(calls).toHaveLength(4);
|
|
1057
1057
|
});
|
|
1058
1058
|
|
|
1059
|
+
it('🔴 every attempt goes with a token signed for IT — a 60 s token outlives no 120 s wait (forge task 3302)', async () => {
|
|
1060
|
+
// The URL was signed once, before the first attempt, so a paced read that waited out 30 s
|
|
1061
|
+
// refusals sent a dead token on its third try, and binary-server answered 403 — not 429.
|
|
1062
|
+
try {
|
|
1063
|
+
let n = 0;
|
|
1064
|
+
reply = () => (n++ < 3 ? limited('30') : ok(new Uint8Array([7, 8])));
|
|
1065
|
+
const store = makeStore({
|
|
1066
|
+
sleep: (ms) => {
|
|
1067
|
+
setSystemTime(new Date(Date.now() + ms));
|
|
1068
|
+
return Promise.resolve();
|
|
1069
|
+
},
|
|
1070
|
+
});
|
|
1071
|
+
await store.get('parts/0', { paced: true });
|
|
1072
|
+
expect(calls).toHaveLength(4);
|
|
1073
|
+
// The last attempt went 90 s after the first: its token must still have time left.
|
|
1074
|
+
const token = new URL(String(calls.at(-1)?.url)).searchParams.get('token') ?? '';
|
|
1075
|
+
const { exp } = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8')) as { exp: number };
|
|
1076
|
+
expect(exp * 1000).toBeGreaterThan(Date.now());
|
|
1077
|
+
} finally {
|
|
1078
|
+
setSystemTime();
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1059
1082
|
it('without paced, a 429 is the typed error carrying retryAfterSec — and the old message', async () => {
|
|
1060
1083
|
const { store, slept } = pacedStore(1, '7');
|
|
1061
1084
|
const error = await store.get('parts/0').catch((e: unknown) => e);
|
|
@@ -51,6 +51,9 @@ import type { FileTokenClaims } from './types.js';
|
|
|
51
51
|
*/
|
|
52
52
|
export type { FileTokenClaims };
|
|
53
53
|
|
|
54
|
+
/** The batched `/internal/ops/meta` client — its own module so the store's graph stays the store's. */
|
|
55
|
+
export { BINARY_META_KEYS_MAX, metaMany, type StoredMeta } from './binaryServerMeta.js';
|
|
56
|
+
|
|
54
57
|
export interface BinaryStoreConfig {
|
|
55
58
|
/** e.g. `http://127.0.0.1:3099` or `https://binary-server.cursedalchemy.com`. */
|
|
56
59
|
baseUrl: string;
|
|
@@ -728,7 +731,9 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
|
|
|
728
731
|
const sendRead = async (
|
|
729
732
|
op: string,
|
|
730
733
|
key: string,
|
|
731
|
-
|
|
734
|
+
/** Signs the URL. Called for EVERY attempt: a read token lives 60 s and a paced read may wait
|
|
735
|
+
* 120 s, so a URL signed once went out dead after the first long retry-after (forge task 3302). */
|
|
736
|
+
mint: () => Promise<string>,
|
|
732
737
|
init: RequestInit | undefined,
|
|
733
738
|
opts: ReadOptions | undefined,
|
|
734
739
|
): Promise<Response> => {
|
|
@@ -737,7 +742,7 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
|
|
|
737
742
|
: 0;
|
|
738
743
|
let waited = 0;
|
|
739
744
|
for (let attempt = 0; ; attempt++) {
|
|
740
|
-
const res = await send(
|
|
745
|
+
const res = await send(await mint(), init);
|
|
741
746
|
if (res.status !== 429) return res;
|
|
742
747
|
const retryAfterSec = retryAfterSeconds(res.headers.get('retry-after'), attempt);
|
|
743
748
|
const ms = retryAfterSec * 1000;
|
|
@@ -875,7 +880,7 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
|
|
|
875
880
|
// short-circuit here — we want the bytes, and only the store has them.
|
|
876
881
|
if (!opts?.fresh && knownAbsent(key)) return null;
|
|
877
882
|
countWireRead(key);
|
|
878
|
-
const res = await sendRead('get', key,
|
|
883
|
+
const res = await sendRead('get', key, () => internalMediaUrl(key), undefined, opts);
|
|
879
884
|
if (res.status === 404) {
|
|
880
885
|
rememberAbsent(key);
|
|
881
886
|
return null;
|
|
@@ -952,7 +957,7 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
|
|
|
952
957
|
if (knownAbsent(key)) return null;
|
|
953
958
|
}
|
|
954
959
|
countWireRead(key);
|
|
955
|
-
const res = await sendRead('meta', key,
|
|
960
|
+
const res = await sendRead('meta', key, () => metaUrl(key), undefined, opts);
|
|
956
961
|
if (res.status === 404) {
|
|
957
962
|
rememberAbsent(key);
|
|
958
963
|
return null;
|
|
@@ -995,7 +1000,7 @@ export function createBinaryStore(cfg: BinaryStoreConfig): BinaryStore {
|
|
|
995
1000
|
if (why instanceof BinaryStoreRateLimitedError) throw why;
|
|
996
1001
|
}
|
|
997
1002
|
countWireRead(key);
|
|
998
|
-
const res = await sendRead('stat', key,
|
|
1003
|
+
const res = await sendRead('stat', key, () => internalMediaUrl(key), { headers: { Range: 'bytes=0-0' } }, opts);
|
|
999
1004
|
if (res.status === 404) {
|
|
1000
1005
|
rememberAbsent(key);
|
|
1001
1006
|
return null;
|