@fias/arche-sdk 2.27.0 → 2.28.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.
@@ -1798,7 +1798,231 @@ describe('SDK hooks', () => {
1798
1798
  api.setCurrentStep('step-b');
1799
1799
  });
1800
1800
  });
1801
+ describe('useArcheAssetBytes', () => {
1802
+ const SIGNED = '/v1/arches/arc_1/assets/aset_1?m=mfst_1&u=usr_1&exp=1&sig=x';
1803
+ const inFiveMinutes = () => Math.floor(Date.now() / 1000) + 300;
1804
+ const entry = (assetId, expiresAt = inFiveMinutes()) => ({
1805
+ assetId,
1806
+ signedUrl: SIGNED.replace('aset_1', assetId),
1807
+ expiresAt,
1808
+ });
1809
+ const bytesResult = (size = 1) => ({
1810
+ contentType: 'audio/mpeg',
1811
+ bytes: new ArrayBuffer(size),
1812
+ sizeBytes: size,
1813
+ });
1814
+ /** The `signedUrls` of the nth bridge call (0-based). */
1815
+ const urlsOfCall = (n) => mockBridge.request.mock.calls[n][1].input.signedUrls;
1816
+ const readCalls = () => mockBridge.request.mock.calls.filter(([type]) => type === 'client_entity_invoke');
1817
+ it('mints a signed URL, then has the host read it through the asset-bytes entity', async () => {
1818
+ const { useArcheAssetBytes, ARCHE_ASSET_BYTES_ENTITY_ID } = require('./hooks');
1819
+ const bytes = new Uint8Array([1, 2]).buffer;
1820
+ mockBridge.request
1821
+ .mockResolvedValueOnce({ assetId: 'aset_1', signedUrl: SIGNED, expiresAt: inFiveMinutes() })
1822
+ .mockResolvedValueOnce({ results: [{ contentType: 'audio/mpeg', bytes, sizeBytes: 2 }] });
1823
+ const result = await useArcheAssetBytes().getBytes('aset_1');
1824
+ expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'arche_assets_get_url', {
1825
+ assetId: 'aset_1',
1826
+ });
1827
+ expect(mockBridge.request).toHaveBeenNthCalledWith(2, 'client_entity_invoke', {
1828
+ entityId: ARCHE_ASSET_BYTES_ENTITY_ID,
1829
+ input: { verb: 'read', signedUrls: [SIGNED] },
1830
+ }, expect.any(Number));
1831
+ expect(result).toEqual({ contentType: 'audio/mpeg', bytes, sizeBytes: 2 });
1832
+ });
1833
+ it("reuses a listed entry's signed URL — one round trip, no get_url", async () => {
1834
+ const { useArcheAssetBytes } = require('./hooks');
1835
+ mockBridge.request.mockResolvedValueOnce({ results: [bytesResult()] });
1836
+ await useArcheAssetBytes().getBytes(entry('aset_1'));
1837
+ expect(mockBridge.request).toHaveBeenCalledTimes(1);
1838
+ expect(urlsOfCall(0)).toEqual([SIGNED]);
1839
+ });
1840
+ it('mints a fresh URL for an entry whose URL is about to expire', async () => {
1841
+ const { useArcheAssetBytes } = require('./hooks');
1842
+ const fresh = SIGNED.replace('exp=1', 'exp=2');
1843
+ mockBridge.request
1844
+ .mockResolvedValueOnce({ assetId: 'aset_1', signedUrl: fresh })
1845
+ .mockResolvedValueOnce({ results: [bytesResult()] });
1846
+ await useArcheAssetBytes().getBytes(entry('aset_1', Math.floor(Date.now() / 1000) + 5));
1847
+ expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'arche_assets_get_url', {
1848
+ assetId: 'aset_1',
1849
+ });
1850
+ expect(urlsOfCall(1)).toEqual([fresh]);
1851
+ });
1852
+ it('retries once with a fresh URL when a reused one has lapsed', async () => {
1853
+ const { useArcheAssetBytes } = require('./hooks');
1854
+ const fresh = SIGNED.replace('exp=1', 'exp=2');
1855
+ mockBridge.request
1856
+ .mockResolvedValueOnce({
1857
+ results: [{ error: { code: 'ASSET_URL_EXPIRED', message: 'expired' } }],
1858
+ })
1859
+ .mockResolvedValueOnce({ assetId: 'aset_1', signedUrl: fresh })
1860
+ .mockResolvedValueOnce({ results: [bytesResult()] });
1861
+ const result = await useArcheAssetBytes().getBytes(entry('aset_1'));
1862
+ expect(result.sizeBytes).toBe(1);
1863
+ expect(mockBridge.request).toHaveBeenCalledTimes(3);
1864
+ expect(urlsOfCall(2)).toEqual([fresh]);
1865
+ });
1866
+ it('turns a refused call into a typed ArcheAssetError', async () => {
1867
+ const { useArcheAssetBytes, ArcheAssetError } = require('./hooks');
1868
+ mockBridge.request.mockResolvedValueOnce({
1869
+ error: { code: 'ASSETS_UNAVAILABLE_IN_PREVIEW', message: 'not in preview' },
1870
+ });
1871
+ const err = await useArcheAssetBytes()
1872
+ .getBytes(entry('aset_1'))
1873
+ .catch((e) => e);
1874
+ expect(err).toBeInstanceOf(ArcheAssetError);
1875
+ expect(err).toMatchObject({
1876
+ code: 'ASSETS_UNAVAILABLE_IN_PREVIEW',
1877
+ message: 'not in preview',
1878
+ });
1879
+ });
1880
+ it("turns one URL's error result into a typed ArcheAssetError", async () => {
1881
+ const { useArcheAssetBytes } = require('./hooks');
1882
+ mockBridge.request.mockResolvedValueOnce({
1883
+ results: [{ error: { code: 'ASSET_URL_FORBIDDEN', message: 'not this arche' } }],
1884
+ });
1885
+ await expect(useArcheAssetBytes().getBytes(entry('aset_1'))).rejects.toMatchObject({
1886
+ code: 'ASSET_URL_FORBIDDEN',
1887
+ message: 'not this arche',
1888
+ });
1889
+ });
1890
+ it('names both permissions when the bridge denies one', async () => {
1891
+ const { useArcheAssetBytes } = require('./hooks');
1892
+ mockBridge.request.mockRejectedValueOnce(new Error('Permission denied: entities:client_invoke not granted'));
1893
+ const err = await useArcheAssetBytes()
1894
+ .getBytes('aset_1')
1895
+ .catch((e) => e);
1896
+ expect(err).toMatchObject({ code: 'PERMISSION_DENIED' });
1897
+ expect(err.message).toMatch(/assets:read.*entities:client_invoke/);
1898
+ });
1899
+ it('maps a rate-limit rejection to RATE_LIMIT and anything else to ASSET_UNAVAILABLE', async () => {
1900
+ const { useArcheAssetBytes } = require('./hooks');
1901
+ mockBridge.request.mockRejectedValueOnce(new Error('Rate limit exceeded'));
1902
+ await expect(useArcheAssetBytes().getBytes('a')).rejects.toMatchObject({
1903
+ code: 'RATE_LIMIT',
1904
+ });
1905
+ mockBridge.request.mockRejectedValueOnce(new Error('Asset not found: a'));
1906
+ await expect(useArcheAssetBytes().getBytes('a')).rejects.toMatchObject({
1907
+ code: 'ASSET_UNAVAILABLE',
1908
+ });
1909
+ });
1910
+ it("maps the bridge's runaway-loop block to RATE_LIMIT and points at getMany", async () => {
1911
+ const { useArcheAssetBytes } = require('./hooks');
1912
+ mockBridge.request.mockRejectedValueOnce(new Error('Bridge call "client_entity_invoke" (entity: x) temporarily blocked — rapid-fire loop detected.'));
1913
+ const err = await useArcheAssetBytes()
1914
+ .getBytes(entry('aset_1'))
1915
+ .catch((e) => e);
1916
+ expect(err).toMatchObject({ code: 'RATE_LIMIT' });
1917
+ expect(err.message).toMatch(/getMany/);
1918
+ });
1919
+ it('sends getBytes calls made together as one host call', async () => {
1920
+ const { useArcheAssetBytes } = require('./hooks');
1921
+ mockBridge.request.mockResolvedValueOnce({
1922
+ results: [bytesResult(1), bytesResult(2), bytesResult(3)],
1923
+ });
1924
+ const { getBytes } = useArcheAssetBytes();
1925
+ const results = await Promise.all(['a1', 'a2', 'a3'].map((id) => getBytes(entry(id))));
1926
+ expect(readCalls()).toHaveLength(1);
1927
+ expect(urlsOfCall(0)).toHaveLength(3);
1928
+ expect(results.map((r) => r.sizeBytes)).toEqual([1, 2, 3]);
1929
+ });
1930
+ it('gives each caller of the same asset its own buffer (decodeAudioData detaches it)', async () => {
1931
+ const { useArcheAssetBytes } = require('./hooks');
1932
+ mockBridge.request.mockResolvedValueOnce({ results: [bytesResult(4)] });
1933
+ const { getBytes } = useArcheAssetBytes();
1934
+ const [first, second] = await Promise.all([getBytes(entry('a1')), getBytes(entry('a1'))]);
1935
+ expect(urlsOfCall(0)).toHaveLength(1);
1936
+ expect(first.bytes).not.toBe(second.bytes);
1937
+ expect(second.bytes.byteLength).toBe(4);
1938
+ });
1939
+ describe('getMany', () => {
1940
+ it('reads 150 sounds in two calls — never one per sound', async () => {
1941
+ const { useArcheAssetBytes } = require('./hooks');
1942
+ mockBridge.request.mockImplementation(async (_type, payload) => ({
1943
+ results: payload.input.signedUrls.map(() => bytesResult()),
1944
+ }));
1945
+ const entries = Array.from({ length: 150 }, (_, i) => entry(`a${i}`));
1946
+ const loaded = await useArcheAssetBytes().getMany(entries);
1947
+ expect(readCalls()).toHaveLength(2);
1948
+ expect(urlsOfCall(0)).toHaveLength(100);
1949
+ expect(urlsOfCall(1)).toHaveLength(50);
1950
+ expect(Object.keys(loaded)).toHaveLength(150);
1951
+ expect(loaded.a149).toMatchObject({ sizeBytes: 1 });
1952
+ });
1953
+ it('returns a failed asset as its ArcheAssetError without failing the rest', async () => {
1954
+ const { useArcheAssetBytes, ArcheAssetError } = require('./hooks');
1955
+ mockBridge.request.mockResolvedValueOnce({
1956
+ results: [
1957
+ bytesResult(),
1958
+ { error: { code: 'ASSET_UNAVAILABLE', message: 'the asset could not be read (404)' } },
1959
+ ],
1960
+ });
1961
+ const loaded = await useArcheAssetBytes().getMany([entry('a1'), entry('a2')]);
1962
+ expect(loaded.a1).toMatchObject({ sizeBytes: 1 });
1963
+ expect(loaded.a2).toBeInstanceOf(ArcheAssetError);
1964
+ expect(loaded.a2).toMatchObject({ code: 'ASSET_UNAVAILABLE' });
1965
+ });
1966
+ it('rejects when the read is refused as a whole', async () => {
1967
+ const { useArcheAssetBytes } = require('./hooks');
1968
+ mockBridge.request.mockResolvedValueOnce({
1969
+ error: { code: 'ASSETS_UNAVAILABLE_IN_PREVIEW', message: 'not in preview' },
1970
+ });
1971
+ await expect(useArcheAssetBytes().getMany([entry('a1')])).rejects.toMatchObject({
1972
+ code: 'ASSETS_UNAVAILABLE_IN_PREVIEW',
1973
+ });
1974
+ });
1975
+ it('reads again, together, the assets that did not fit the call byte budget', async () => {
1976
+ const { useArcheAssetBytes } = require('./hooks');
1977
+ const overBudget = { error: { code: 'ASSET_TOO_LARGE', message: 'read it on its own' } };
1978
+ mockBridge.request
1979
+ .mockResolvedValueOnce({ results: [bytesResult(), overBudget, overBudget] })
1980
+ .mockResolvedValueOnce({ results: [bytesResult(), overBudget] })
1981
+ .mockResolvedValueOnce({ results: [bytesResult()] });
1982
+ const loaded = await useArcheAssetBytes().getMany([entry('a1'), entry('a2'), entry('a3')]);
1983
+ expect(urlsOfCall(1)).toHaveLength(2);
1984
+ expect(urlsOfCall(2)).toHaveLength(1);
1985
+ expect(Object.values(loaded).every((r) => !(r instanceof Error))).toBe(true);
1986
+ });
1987
+ it('refreshes at most 10 lapsed URLs per call and asks for a fresh list past that', async () => {
1988
+ const { useArcheAssetBytes } = require('./hooks');
1989
+ mockBridge.request.mockImplementation(async (type, payload) => type === 'arche_assets_get_url'
1990
+ ? { assetId: payload.assetId, signedUrl: SIGNED }
1991
+ : {
1992
+ results: payload.input.signedUrls.map(() => bytesResult()),
1993
+ });
1994
+ const lapsed = Math.floor(Date.now() / 1000) - 1;
1995
+ const entries = Array.from({ length: 15 }, (_, i) => entry(`a${i}`, lapsed));
1996
+ const loaded = await useArcheAssetBytes().getMany(entries);
1997
+ const minted = mockBridge.request.mock.calls.filter(([type]) => type === 'arche_assets_get_url');
1998
+ expect(minted).toHaveLength(10);
1999
+ const expired = Object.values(loaded).filter((r) => r instanceof Error && r.code === 'ASSET_URL_EXPIRED');
2000
+ expect(expired).toHaveLength(5);
2001
+ expect(expired[0].message).toMatch(/list the assets again/);
2002
+ });
2003
+ });
2004
+ });
1801
2005
  describe('useArcheAssets', () => {
2006
+ // Compile-time only (`pnpm typecheck:tests` fails if these drift): code
2007
+ // written before the library held sounds keeps its image types.
2008
+ function listingTypes(api) {
2009
+ return async () => {
2010
+ const images = await api.list({ limit: 10 });
2011
+ const width = images.entries[0].width;
2012
+ const refreshed = await api.getUrl('aset_1');
2013
+ const height = refreshed.height;
2014
+ const mixed = await api.list({ kinds: ['image', 'audio'] });
2015
+ // @ts-expect-error a listing with kinds may hold sounds, which have no width
2016
+ const unchecked = mixed.entries[0].width;
2017
+ const first = mixed.entries[0];
2018
+ const narrowed = first.contentType.startsWith('image/') && first.width !== null ? first.width : undefined;
2019
+ const sound = await api.getUrl('aset_2');
2020
+ return [width, height, unchecked, narrowed, sound];
2021
+ };
2022
+ }
2023
+ it('keeps an images-only listing typed as images', () => {
2024
+ expect(typeof listingTypes).toBe('function');
2025
+ });
1802
2026
  it('list forwards to arche_assets_list with the cursor/limit options', async () => {
1803
2027
  const { useArcheAssets } = require('./hooks');
1804
2028
  mockBridge.request.mockResolvedValueOnce({ entries: [], nextCursor: null });
@@ -1821,6 +2045,17 @@ describe('SDK hooks', () => {
1821
2045
  tags: ['category:background'],
1822
2046
  });
1823
2047
  });
2048
+ it('list and index forward the kinds filter', async () => {
2049
+ const { useArcheAssets } = require('./hooks');
2050
+ mockBridge.request
2051
+ .mockResolvedValueOnce({ entries: [], nextCursor: null })
2052
+ .mockResolvedValueOnce({ groups: [] });
2053
+ const api = useArcheAssets();
2054
+ await api.list({ kinds: ['audio'] });
2055
+ await api.index({ kinds: ['image', 'audio'] });
2056
+ expect(mockBridge.request).toHaveBeenNthCalledWith(1, 'arche_assets_list', expect.objectContaining({ kinds: ['audio'] }));
2057
+ expect(mockBridge.request).toHaveBeenNthCalledWith(2, 'arche_assets_index', expect.objectContaining({ kinds: ['image', 'audio'] }));
2058
+ });
1824
2059
  it('index forwards to arche_assets_index with tags + groupBy', async () => {
1825
2060
  const { useArcheAssets } = require('./hooks');
1826
2061
  mockBridge.request.mockResolvedValueOnce({ groups: [] });
@@ -1846,6 +2081,100 @@ describe('SDK hooks', () => {
1846
2081
  expect(asset.assetId).toBe('aset_1');
1847
2082
  });
1848
2083
  });
2084
+ describe('useLocalStore (ADR-031)', () => {
2085
+ const ENTITY = 'ent_intg_plugin_local_store_0000';
2086
+ const load = () => {
2087
+ const { useLocalStore, FiasLocalStoreError } = require('./hooks');
2088
+ return { store: useLocalStore(), FiasLocalStoreError };
2089
+ };
2090
+ it('puts through the generic client_entity_invoke op, verb in the input', async () => {
2091
+ const { store } = load();
2092
+ mockBridge.request.mockResolvedValueOnce({ sizeBytes: 7 });
2093
+ expect(await store.put('session/1', { a: 1 })).toEqual({ sizeBytes: 7 });
2094
+ expect(mockBridge.request).toHaveBeenCalledWith('client_entity_invoke', { entityId: ENTITY, input: { verb: 'put', key: 'session/1', value: { a: 1 } } }, 120000);
2095
+ });
2096
+ it('reads each kind back through the typed getters, and null for a missing key', async () => {
2097
+ const { store } = load();
2098
+ const meta = { key: 'k', sizeBytes: 1, updatedAt: '2026-10-13T00:00:00.000Z' };
2099
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: 'hi' } });
2100
+ expect(await store.getText('k')).toBe('hi');
2101
+ const bytes = new Uint8Array([1, 2]).buffer;
2102
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: bytes } });
2103
+ expect(await store.getBytes('k')).toBe(bytes);
2104
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: { n: 1 } } });
2105
+ expect(await store.getJson('k')).toEqual({ n: 1 });
2106
+ mockBridge.request.mockResolvedValueOnce({ entry: null });
2107
+ expect(await store.getJson('k')).toBeNull();
2108
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: 'raw' } });
2109
+ expect(await store.get('k')).toEqual({ ...meta, value: 'raw' });
2110
+ });
2111
+ it('getJson parses a text entry, so put(JSON.stringify(x)) reads back like put(x)', async () => {
2112
+ const { store, FiasLocalStoreError } = load();
2113
+ const meta = { key: 'k', sizeBytes: 7, updatedAt: '2026-10-13T00:00:00.000Z' };
2114
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: '{"n":1}' } });
2115
+ expect(await store.getJson('k')).toEqual({ n: 1 });
2116
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: 'not json' } });
2117
+ const err = await store.getJson('k').catch((e) => e);
2118
+ expect(err).toBeInstanceOf(FiasLocalStoreError);
2119
+ expect(err).toMatchObject({ code: 'LOCAL_STORE_WRONG_KIND' });
2120
+ });
2121
+ it('the typed getters refuse the wrong kind with LOCAL_STORE_WRONG_KIND', async () => {
2122
+ const { store } = load();
2123
+ const meta = { key: 'k', sizeBytes: 2, updatedAt: '2026-10-13T00:00:00.000Z' };
2124
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: { n: 1 } } });
2125
+ await expect(store.getText('k')).rejects.toMatchObject({ code: 'LOCAL_STORE_WRONG_KIND' });
2126
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: 'text' } });
2127
+ await expect(store.getBytes('k')).rejects.toMatchObject({ code: 'LOCAL_STORE_WRONG_KIND' });
2128
+ mockBridge.request.mockResolvedValueOnce({ entry: { ...meta, value: new ArrayBuffer(1) } });
2129
+ await expect(store.getJson('k')).rejects.toMatchObject({ code: 'LOCAL_STORE_WRONG_KIND' });
2130
+ });
2131
+ it('throws a coded FiasLocalStoreError when the host returns { error }', async () => {
2132
+ const { store, FiasLocalStoreError } = load();
2133
+ mockBridge.request.mockResolvedValueOnce({
2134
+ error: { code: 'LOCAL_STORE_QUOTA_EXCEEDED', message: 'over budget' },
2135
+ });
2136
+ const err = await store.put('k', 'v').catch((e) => e);
2137
+ expect(err).toBeInstanceOf(FiasLocalStoreError);
2138
+ expect(err).toMatchObject({ code: 'LOCAL_STORE_QUOTA_EXCEEDED', message: 'over budget' });
2139
+ });
2140
+ it.each([
2141
+ ['Permission denied: storage:local not granted', undefined, 'PERMISSION_DENIED'],
2142
+ ['refused', 'PERMISSION_DENIED', 'PERMISSION_DENIED'],
2143
+ ['Rate limit exceeded for ent_intg_plugin_local_store_0000', undefined, 'RATE_LIMIT'],
2144
+ ['slow down', 'RATE_LIMIT', 'RATE_LIMIT'],
2145
+ ['Bridge request timed out', 'TIMEOUT', 'LOCAL_STORE_UNAVAILABLE'],
2146
+ ])('maps a bridge failure (%s, code %s) to %s', async (message, code, expected) => {
2147
+ const { store } = load();
2148
+ mockBridge.request.mockRejectedValueOnce(Object.assign(new Error(message), { code }));
2149
+ await expect(store.list()).rejects.toMatchObject({ code: expected });
2150
+ });
2151
+ it('lists, deletes, clears and estimates with the documented shapes', async () => {
2152
+ const { store } = load();
2153
+ const entries = [{ key: 'a', sizeBytes: 1, updatedAt: '2026-10-13T00:00:00.000Z' }];
2154
+ mockBridge.request.mockResolvedValueOnce({ entries });
2155
+ expect(await store.list('a')).toEqual(entries);
2156
+ expect(mockBridge.request).toHaveBeenLastCalledWith('client_entity_invoke', { entityId: ENTITY, input: { verb: 'list', prefix: 'a' } }, 120000);
2157
+ mockBridge.request.mockResolvedValueOnce({});
2158
+ await expect(store.delete('a')).resolves.toBeUndefined();
2159
+ mockBridge.request.mockResolvedValueOnce({ deleted: 3 });
2160
+ expect(await store.clear()).toEqual({ deleted: 3 });
2161
+ mockBridge.request.mockResolvedValueOnce({ usedBytes: 10, budgetBytes: 100 });
2162
+ expect(await store.estimate()).toEqual({ usedBytes: 10, budgetBytes: 100 });
2163
+ });
2164
+ it('getMany reads several entries in one call, in order, null for a missing key', async () => {
2165
+ const { store } = load();
2166
+ const a = { key: 'a', value: 'x', sizeBytes: 1, updatedAt: '2026-10-14T00:00:00.000Z' };
2167
+ mockBridge.request.mockResolvedValueOnce({ entries: [a, null] });
2168
+ expect(await store.getMany(['a', 'gone'])).toEqual([a, null]);
2169
+ expect(mockBridge.request).toHaveBeenLastCalledWith('client_entity_invoke', { entityId: ENTITY, input: { verb: 'get_many', keys: ['a', 'gone'] } }, 120000);
2170
+ mockBridge.request.mockResolvedValueOnce({
2171
+ error: { code: 'LOCAL_STORE_VALUE_TOO_LARGE', message: 'read fewer keys at a time' },
2172
+ });
2173
+ await expect(store.getMany(['a', 'b'])).rejects.toMatchObject({
2174
+ code: 'LOCAL_STORE_VALUE_TOO_LARGE',
2175
+ });
2176
+ });
2177
+ });
1849
2178
  describe('useFiasContent (content packs, ADR-021)', () => {
1850
2179
  const ENTITY = 'ent_intg_plugin_content_00000000';
1851
2180
  const load = () => {