@org-quicko/silo-client 1.0.1 → 1.1.1

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.
Files changed (46) hide show
  1. package/README.md +49 -1
  2. package/dist/cache/cache-mode.d.cts +2 -0
  3. package/dist/cache/cache-mode.d.ts +2 -0
  4. package/dist/cache/response-cache.d.cts +16 -0
  5. package/dist/cache/response-cache.d.ts +16 -0
  6. package/dist/cache/silo-cache-options.d.cts +7 -0
  7. package/dist/cache/silo-cache-options.d.ts +7 -0
  8. package/dist/cache/silo-cache.d.cts +11 -0
  9. package/dist/cache/silo-cache.d.ts +11 -0
  10. package/dist/errors/network-error.d.cts +5 -0
  11. package/dist/errors/network-error.d.ts +5 -0
  12. package/dist/index.cjs +264 -74
  13. package/dist/index.d.cts +4 -0
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.js +264 -74
  16. package/dist/media/media-asset.d.cts +17 -0
  17. package/dist/media/media-asset.d.ts +17 -0
  18. package/dist/media/media-file.d.cts +16 -0
  19. package/dist/media/media-file.d.ts +16 -0
  20. package/dist/media/media-page.d.cts +1 -0
  21. package/dist/media/media-page.d.ts +1 -0
  22. package/dist/media/media-replace.d.cts +17 -0
  23. package/dist/media/media-replace.d.ts +17 -0
  24. package/dist/media/media-usage-page.d.cts +1 -0
  25. package/dist/media/media-usage-page.d.ts +1 -0
  26. package/dist/media/media.d.cts +0 -4
  27. package/dist/media/media.d.ts +0 -4
  28. package/dist/request-options.d.cts +3 -0
  29. package/dist/request-options.d.ts +3 -0
  30. package/dist/silo-options.d.cts +4 -0
  31. package/dist/silo-options.d.ts +4 -0
  32. package/dist/silo.d.cts +3 -0
  33. package/dist/silo.d.ts +3 -0
  34. package/dist/transport/api-path.d.cts +1 -0
  35. package/dist/transport/api-path.d.ts +1 -0
  36. package/dist/transport/caching-transport.d.cts +14 -0
  37. package/dist/transport/caching-transport.d.ts +14 -0
  38. package/dist/transport/fetch-transport.d.cts +31 -0
  39. package/dist/transport/fetch-transport.d.ts +31 -0
  40. package/dist/transport/prepared-transport-request.d.cts +6 -0
  41. package/dist/transport/prepared-transport-request.d.ts +6 -0
  42. package/dist/transport/transport-request.d.cts +2 -0
  43. package/dist/transport/transport-request.d.ts +2 -0
  44. package/dist/transport/transport.d.cts +2 -39
  45. package/dist/transport/transport.d.ts +2 -39
  46. package/package.json +6 -3
package/dist/index.js CHANGED
@@ -84,6 +84,9 @@ class ApiPath {
84
84
  static mediaAssetUsages(id) {
85
85
  return `${ApiPath.mediaAsset(id)}/usages`;
86
86
  }
87
+ static mediaAssetContent(id) {
88
+ return `${ApiPath.mediaAsset(id)}/content`;
89
+ }
87
90
  static mediaBulkDelete() {
88
91
  return "/api/media/delete";
89
92
  }
@@ -130,6 +133,22 @@ class MediaAssetMapper {
130
133
  }
131
134
  }
132
135
 
136
+ // src/media/media-file.ts
137
+ class MediaFile {
138
+ static toBlob(bytes, contentType) {
139
+ if (bytes instanceof Blob)
140
+ return bytes;
141
+ return new Blob([bytes], contentType ? { type: contentType } : undefined);
142
+ }
143
+ static nameOf(input, explicit) {
144
+ if (explicit)
145
+ return explicit;
146
+ if (input instanceof File && input.name)
147
+ return input.name;
148
+ throw new Error("media: a Blob has no filename — pass { filename } explicitly");
149
+ }
150
+ }
151
+
133
152
  // src/media/media-reference.ts
134
153
  class MediaReference {
135
154
  static Scheme = "silo://media/";
@@ -189,12 +208,14 @@ var DefaultUsageLimit = 50;
189
208
  class MediaUsagePage extends Page {
190
209
  transport;
191
210
  assetId;
211
+ requestOptions;
192
212
  visible;
193
213
  visibleCapped;
194
- constructor(rows, total, visible, visibleCapped, window, transport, assetId) {
214
+ constructor(rows, total, visible, visibleCapped, window, transport, assetId, requestOptions) {
195
215
  super(rows, total, window);
196
216
  this.transport = transport;
197
217
  this.assetId = assetId;
218
+ this.requestOptions = requestOptions;
198
219
  this.visible = visible;
199
220
  this.visibleCapped = visibleCapped;
200
221
  }
@@ -209,11 +230,11 @@ class MediaUsagePage extends Page {
209
230
  }
210
231
  async next(options) {
211
232
  const window = this.windowForNext();
212
- return window ? MediaUsagePage.loadWindow(this.transport, this.assetId, window, options) : null;
233
+ return window ? MediaUsagePage.loadWindow(this.transport, this.assetId, window, { ...this.requestOptions, ...options }) : null;
213
234
  }
214
235
  async previous(options) {
215
236
  const window = this.window.previous();
216
- return window ? MediaUsagePage.loadWindow(this.transport, this.assetId, window, options) : null;
237
+ return window ? MediaUsagePage.loadWindow(this.transport, this.assetId, window, { ...this.requestOptions, ...options }) : null;
217
238
  }
218
239
  static load(transport, assetId, query, options) {
219
240
  const window = new PageWindow(query.limit ?? DefaultUsageLimit, query.offset ?? 0);
@@ -225,10 +246,11 @@ class MediaUsagePage extends Page {
225
246
  path: ApiPath.mediaAssetUsages(assetId),
226
247
  query: { limit: window.limit, offset: window.offset },
227
248
  signal: options?.signal,
228
- timeoutMilliseconds: options?.timeoutMilliseconds
249
+ timeoutMilliseconds: options?.timeoutMilliseconds,
250
+ cache: options?.cache
229
251
  });
230
252
  const mapped = MediaAssetMapper.toUsagePage(body);
231
- return new MediaUsagePage(mapped.items, mapped.total, mapped.visible, mapped.visibleCapped, window, transport, assetId);
253
+ return new MediaUsagePage(mapped.items, mapped.total, mapped.visible, mapped.visibleCapped, window, transport, assetId, options);
232
254
  }
233
255
  }
234
256
 
@@ -291,6 +313,22 @@ class MediaAsset {
291
313
  async setTags(tags, options) {
292
314
  return this.patch({ tags: [...tags] }, options);
293
315
  }
316
+ async replace(input, options) {
317
+ const form = new FormData;
318
+ if (input instanceof Blob) {
319
+ form.set("file", input, MediaFile.nameOf(input, options?.filename));
320
+ } else {
321
+ form.set("file", MediaFile.toBlob(input.bytes, input.contentType), input.filename);
322
+ }
323
+ const payload = await this.transport.upload({
324
+ method: "POST",
325
+ path: ApiPath.mediaAssetContent(this.id),
326
+ signal: options?.signal,
327
+ timeoutMilliseconds: options?.timeoutMilliseconds
328
+ }, form);
329
+ this.record = MediaAssetMapper.toRecord(payload);
330
+ return this;
331
+ }
294
332
  async delete(options) {
295
333
  await this.transport.empty({
296
334
  method: "DELETE",
@@ -308,7 +346,8 @@ class MediaAsset {
308
346
  method: "GET",
309
347
  path: ApiPath.mediaAsset(this.id),
310
348
  signal: options?.signal,
311
- timeoutMilliseconds: options?.timeoutMilliseconds
349
+ timeoutMilliseconds: options?.timeoutMilliseconds,
350
+ cache: options?.cache ?? "refresh"
312
351
  });
313
352
  this.record = MediaAssetMapper.toRecord(payload);
314
353
  return this;
@@ -370,7 +409,8 @@ class MediaFolders {
370
409
  method: "GET",
371
410
  path: ApiPath.mediaFolders(),
372
411
  signal: options?.signal,
373
- timeoutMilliseconds: options?.timeoutMilliseconds
412
+ timeoutMilliseconds: options?.timeoutMilliseconds,
413
+ cache: options?.cache
374
414
  });
375
415
  return body.items;
376
416
  }
@@ -412,21 +452,23 @@ class MediaFolders {
412
452
  class MediaPage extends Page {
413
453
  transport;
414
454
  wireQuery;
415
- constructor(rows, total, window, transport, wireQuery) {
455
+ requestOptions;
456
+ constructor(rows, total, window, transport, wireQuery, requestOptions) {
416
457
  super(rows, total, window);
417
458
  this.transport = transport;
418
459
  this.wireQuery = wireQuery;
460
+ this.requestOptions = requestOptions;
419
461
  }
420
462
  get files() {
421
463
  return this.rows;
422
464
  }
423
465
  async next(options) {
424
466
  const window = this.windowForNext();
425
- return window ? MediaPage.loadWindow(this.transport, this.wireQuery, window, options) : null;
467
+ return window ? MediaPage.loadWindow(this.transport, this.wireQuery, window, { ...this.requestOptions, ...options }) : null;
426
468
  }
427
469
  async previous(options) {
428
470
  const window = this.window.previous();
429
- return window ? MediaPage.loadWindow(this.transport, this.wireQuery, window, options) : null;
471
+ return window ? MediaPage.loadWindow(this.transport, this.wireQuery, window, { ...this.requestOptions, ...options }) : null;
430
472
  }
431
473
  static async loadWindow(transport, wireQuery, window, options) {
432
474
  const body = await transport.json({
@@ -434,10 +476,11 @@ class MediaPage extends Page {
434
476
  path: ApiPath.media(),
435
477
  query: { ...wireQuery, limit: window.limit, offset: window.offset },
436
478
  signal: options?.signal,
437
- timeoutMilliseconds: options?.timeoutMilliseconds
479
+ timeoutMilliseconds: options?.timeoutMilliseconds,
480
+ cache: options?.cache
438
481
  });
439
482
  const rows = body.items.map((payload) => new MediaAsset(transport, MediaAssetMapper.toRecord(payload)));
440
- return new MediaPage(rows, body.total, new PageWindow(body.limit, body.offset), transport, wireQuery);
483
+ return new MediaPage(rows, body.total, new PageWindow(body.limit, body.offset), transport, wireQuery, options);
441
484
  }
442
485
  }
443
486
 
@@ -524,11 +567,11 @@ class Media {
524
567
  let options;
525
568
  if (input instanceof Blob) {
526
569
  const fileOptions = second ?? {};
527
- form.set("file", input, Media.filenameOf(input, fileOptions.filename));
570
+ form.set("file", input, MediaFile.nameOf(input, fileOptions.filename));
528
571
  folder = fileOptions.folder;
529
572
  options = third ?? fileOptions;
530
573
  } else {
531
- form.set("file", Media.toBlob(input.bytes, input.contentType), input.filename);
574
+ form.set("file", MediaFile.toBlob(input.bytes, input.contentType), input.filename);
532
575
  folder = input.folder;
533
576
  options = second;
534
577
  }
@@ -557,7 +600,8 @@ class Media {
557
600
  method: "GET",
558
601
  path: ApiPath.mediaAsset(id),
559
602
  signal: options?.signal,
560
- timeoutMilliseconds: options?.timeoutMilliseconds
603
+ timeoutMilliseconds: options?.timeoutMilliseconds,
604
+ cache: options?.cache
561
605
  });
562
606
  return new MediaAsset(this.transport, MediaAssetMapper.toRecord(payload));
563
607
  }
@@ -566,7 +610,8 @@ class Media {
566
610
  method: "GET",
567
611
  path: ApiPath.mediaExtensions(),
568
612
  signal: options?.signal,
569
- timeoutMilliseconds: options?.timeoutMilliseconds
613
+ timeoutMilliseconds: options?.timeoutMilliseconds,
614
+ cache: options?.cache
570
615
  });
571
616
  return body.items;
572
617
  }
@@ -596,18 +641,6 @@ class Media {
596
641
  sort: query.sort
597
642
  };
598
643
  }
599
- static toBlob(bytes, contentType) {
600
- if (bytes instanceof Blob)
601
- return bytes;
602
- return new Blob([bytes], contentType ? { type: contentType } : undefined);
603
- }
604
- static filenameOf(input, explicit) {
605
- if (explicit)
606
- return explicit;
607
- if (input instanceof File && input.name)
608
- return input.name;
609
- throw new Error("media upload: a Blob has no filename — pass { filename } explicitly");
610
- }
611
644
  }
612
645
 
613
646
  // src/variables/variable.ts
@@ -1291,6 +1324,132 @@ class Projects {
1291
1324
  }
1292
1325
  }
1293
1326
 
1327
+ // src/transport/caching-transport.ts
1328
+ class CachingTransport {
1329
+ #fetchTransport;
1330
+ #cache;
1331
+ constructor(fetchTransport, cache) {
1332
+ this.#fetchTransport = fetchTransport;
1333
+ this.#cache = cache;
1334
+ }
1335
+ async json(request) {
1336
+ if (request.method !== "GET")
1337
+ return this.write(() => this.#fetchTransport.json(request));
1338
+ if (request.cache === "bypass")
1339
+ return this.#fetchTransport.json(request);
1340
+ if (request.signal?.aborted)
1341
+ throw new RequestAbortedError(request.method, request.path);
1342
+ let prepared;
1343
+ try {
1344
+ prepared = this.#fetchTransport.prepare(request);
1345
+ } catch (caught) {
1346
+ throw this.#fetchTransport.preparationFailure(request, caught);
1347
+ }
1348
+ const key = JSON.stringify([prepared.url, [...prepared.headers]]);
1349
+ if (request.cache !== "refresh") {
1350
+ const cached = this.#cache.get(key);
1351
+ if (cached)
1352
+ return cached.body;
1353
+ }
1354
+ const ticket = this.#cache.start(key);
1355
+ try {
1356
+ const body = await this.#fetchTransport.jsonPrepared(request, prepared);
1357
+ this.#cache.set(key, ticket, body);
1358
+ return body;
1359
+ } finally {
1360
+ this.#cache.finish(key, ticket);
1361
+ }
1362
+ }
1363
+ empty(request) {
1364
+ return request.method === "GET" ? this.#fetchTransport.empty(request) : this.write(() => this.#fetchTransport.empty(request));
1365
+ }
1366
+ stream(request) {
1367
+ return request.method === "GET" ? this.#fetchTransport.stream(request) : this.write(() => this.#fetchTransport.stream(request));
1368
+ }
1369
+ upload(request, form) {
1370
+ return request.method === "GET" ? this.#fetchTransport.upload(request, form) : this.write(() => this.#fetchTransport.upload(request, form));
1371
+ }
1372
+ async write(dispatch) {
1373
+ this.#cache.clear();
1374
+ try {
1375
+ return await dispatch();
1376
+ } finally {
1377
+ this.#cache.clear();
1378
+ }
1379
+ }
1380
+ }
1381
+
1382
+ // src/cache/response-cache.ts
1383
+ import { TTLCache } from "@isaacs/ttlcache";
1384
+
1385
+ class ResponseCache {
1386
+ #store;
1387
+ #pending = new Map;
1388
+ constructor(options) {
1389
+ this.#store = new TTLCache({
1390
+ ttl: options.ttlMilliseconds,
1391
+ ...options.maxEntries === undefined || options.maxEntries === Infinity ? {} : { max: options.maxEntries },
1392
+ checkAgeOnGet: true,
1393
+ updateAgeOnGet: false
1394
+ });
1395
+ }
1396
+ get size() {
1397
+ return this.#store.size;
1398
+ }
1399
+ get(key) {
1400
+ const cached = this.#store.get(key);
1401
+ return cached === undefined ? undefined : { body: structuredClone(cached.body) };
1402
+ }
1403
+ set(key, ticket, body) {
1404
+ if (this.#pending.get(key) !== ticket)
1405
+ return;
1406
+ this.#store.set(key, { body: structuredClone(body) });
1407
+ }
1408
+ start(key) {
1409
+ const ticket = Symbol();
1410
+ this.#pending.set(key, ticket);
1411
+ return ticket;
1412
+ }
1413
+ finish(key, ticket) {
1414
+ if (this.#pending.get(key) === ticket)
1415
+ this.#pending.delete(key);
1416
+ }
1417
+ clear() {
1418
+ this.#pending.clear();
1419
+ this.#store.clear();
1420
+ }
1421
+ }
1422
+
1423
+ // src/cache/silo-cache.ts
1424
+ class SiloCache {
1425
+ #responseCache;
1426
+ constructor(options) {
1427
+ if (options !== undefined)
1428
+ SiloCache.validate(options);
1429
+ this.#responseCache = options === undefined ? undefined : new ResponseCache(options);
1430
+ }
1431
+ get enabled() {
1432
+ return this.#responseCache !== undefined;
1433
+ }
1434
+ get size() {
1435
+ return this.#responseCache?.size ?? 0;
1436
+ }
1437
+ clear() {
1438
+ this.#responseCache?.clear();
1439
+ }
1440
+ wrap(fetchTransport) {
1441
+ return this.#responseCache ? new CachingTransport(fetchTransport, this.#responseCache) : fetchTransport;
1442
+ }
1443
+ static validate(options) {
1444
+ if (!Number.isSafeInteger(options.ttlMilliseconds) || options.ttlMilliseconds <= 0) {
1445
+ throw new TypeError("SiloOptions.cache.ttlMilliseconds must be a positive finite safe integer");
1446
+ }
1447
+ if (options.maxEntries !== undefined && options.maxEntries !== Infinity && (!Number.isSafeInteger(options.maxEntries) || options.maxEntries <= 0)) {
1448
+ throw new TypeError("SiloOptions.cache.maxEntries must be a positive safe integer or Infinity");
1449
+ }
1450
+ }
1451
+ }
1452
+
1294
1453
  // src/errors/silo-error.ts
1295
1454
  class SiloError extends Error {
1296
1455
  status;
@@ -1471,12 +1630,19 @@ class NetworkError extends Error {
1471
1630
  method;
1472
1631
  path;
1473
1632
  constructor(method, path, cause) {
1474
- super(`network error on ${method} ${path}: the request never reached the server`, { cause });
1633
+ super(`network error on ${method} ${path}: the request never reached the server${NetworkError.reason(cause)}`, { cause });
1475
1634
  this.name = "NetworkError";
1476
1635
  this.method = method;
1477
1636
  this.path = path;
1478
1637
  Object.setPrototypeOf(this, NetworkError.prototype);
1479
1638
  }
1639
+ static reason(cause) {
1640
+ if (cause instanceof Error && cause.message)
1641
+ return ` (${cause.message})`;
1642
+ if (typeof cause === "string" && cause)
1643
+ return ` (${cause})`;
1644
+ return "";
1645
+ }
1480
1646
  }
1481
1647
 
1482
1648
  // src/errors/timeout-error.ts
@@ -1578,60 +1744,80 @@ class ResponseDecoder {
1578
1744
  }
1579
1745
  }
1580
1746
 
1581
- // src/transport/transport.ts
1582
- class Transport {
1583
- url;
1584
- key;
1585
- headers;
1586
- timeoutMilliseconds;
1587
- fetchFunction;
1747
+ // src/transport/fetch-transport.ts
1748
+ class FetchTransport {
1749
+ #url;
1750
+ #key;
1751
+ #headers;
1752
+ #timeoutMilliseconds;
1753
+ #fetchFunction;
1588
1754
  constructor(options) {
1589
- this.url = Transport.normalizeUrl(options.url);
1590
- this.key = options.key;
1591
- this.headers = options.headers ?? {};
1592
- this.timeoutMilliseconds = options.timeoutMilliseconds;
1593
- this.fetchFunction = options.fetch ?? fetch;
1755
+ this.#url = FetchTransport.normalizeUrl(options.url);
1756
+ this.#key = options.key;
1757
+ this.#headers = options.headers ?? {};
1758
+ this.#timeoutMilliseconds = options.timeoutMilliseconds;
1759
+ this.#fetchFunction = FetchTransport.resolveFetch(options.fetch);
1594
1760
  }
1595
1761
  async json(request) {
1596
1762
  const response = await this.execute(request);
1597
1763
  return await ResponseDecoder.decode(response, request, true);
1598
1764
  }
1765
+ async jsonPrepared(request, prepared) {
1766
+ const response = await this.execute(request, prepared);
1767
+ return await ResponseDecoder.decode(response, request, true);
1768
+ }
1599
1769
  async empty(request) {
1600
1770
  const response = await this.execute(request);
1601
1771
  await ResponseDecoder.decode(response, request, false);
1602
1772
  }
1603
1773
  async stream(request) {
1604
- const response = await this.execute(request);
1605
- return response.body;
1774
+ return (await this.execute(request)).body;
1606
1775
  }
1607
1776
  async upload(request, form) {
1608
- const response = await this.execute(request, form);
1777
+ const response = await this.execute(request, undefined, form);
1609
1778
  return await ResponseDecoder.decode(response, request, true);
1610
1779
  }
1611
1780
  withKey(key) {
1612
- return new Transport({ ...this.snapshot(), key });
1781
+ return new FetchTransport({ ...this.snapshot(), key });
1613
1782
  }
1614
1783
  withUrl(url) {
1615
- return new Transport({ ...this.snapshot(), url });
1784
+ return new FetchTransport({ ...this.snapshot(), url });
1785
+ }
1786
+ prepare(request, form) {
1787
+ const rawHeaders = { ...this.#headers, ...request.headers };
1788
+ if (this.#key)
1789
+ rawHeaders.Authorization = `Bearer ${this.#key}`;
1790
+ if (!form && request.body !== undefined && !("Content-Type" in rawHeaders)) {
1791
+ rawHeaders["Content-Type"] = "application/json";
1792
+ }
1793
+ return {
1794
+ url: `${this.#url}${request.path}${QueryString.build(request.query)}`,
1795
+ headers: new Headers(rawHeaders),
1796
+ body: form ?? (request.body === undefined ? undefined : JSON.stringify(request.body))
1797
+ };
1798
+ }
1799
+ preparationFailure(request, caught) {
1800
+ return new NetworkError(request.method, request.path, caught);
1616
1801
  }
1617
1802
  snapshot() {
1618
1803
  return {
1619
- url: this.url,
1620
- key: this.key,
1621
- headers: this.headers,
1622
- timeoutMilliseconds: this.timeoutMilliseconds,
1623
- fetch: this.fetchFunction
1804
+ url: this.#url,
1805
+ key: this.#key,
1806
+ headers: this.#headers,
1807
+ timeoutMilliseconds: this.#timeoutMilliseconds,
1808
+ fetch: this.#fetchFunction
1624
1809
  };
1625
1810
  }
1626
- async execute(request, form) {
1627
- const abortSignals = new AbortSignals(request.signal, request.timeoutMilliseconds ?? this.timeoutMilliseconds);
1628
- const url = `${this.url}${request.path}${QueryString.build(request.query)}`;
1811
+ async execute(request, prepared, form) {
1812
+ const abortSignals = new AbortSignals(request.signal, request.timeoutMilliseconds ?? this.#timeoutMilliseconds);
1813
+ const sendRequest = this.#fetchFunction;
1629
1814
  let response;
1630
1815
  try {
1631
- response = await this.fetchFunction(url, {
1816
+ const outbound = prepared ?? this.prepare(request, form);
1817
+ response = await sendRequest(outbound.url, {
1632
1818
  method: request.method,
1633
- headers: this.buildHeaders(request, Boolean(form)),
1634
- body: form ?? (request.body === undefined ? undefined : JSON.stringify(request.body)),
1819
+ headers: outbound.headers,
1820
+ body: outbound.body,
1635
1821
  signal: abortSignals.signal
1636
1822
  });
1637
1823
  } catch (caught) {
@@ -1645,44 +1831,44 @@ class Transport {
1645
1831
  return response;
1646
1832
  }
1647
1833
  transportFailure(request, abortSignals, caught) {
1648
- const firedBy = abortSignals.firedBy();
1649
- if (firedBy === "timeout") {
1650
- return new TimeoutError(request.method, request.path, request.timeoutMilliseconds ?? this.timeoutMilliseconds ?? 0);
1834
+ if (abortSignals.firedBy() === "timeout") {
1835
+ return new TimeoutError(request.method, request.path, request.timeoutMilliseconds ?? this.#timeoutMilliseconds ?? 0);
1651
1836
  }
1652
- if (firedBy === "caller") {
1837
+ if (abortSignals.firedBy() === "caller")
1653
1838
  return new RequestAbortedError(request.method, request.path);
1654
- }
1655
1839
  return new NetworkError(request.method, request.path, caught);
1656
1840
  }
1657
- buildHeaders(request, isUpload) {
1658
- const headers = { ...this.headers, ...request.headers };
1659
- if (this.key)
1660
- headers["Authorization"] = `Bearer ${this.key}`;
1661
- if (!isUpload && request.body !== undefined && !("Content-Type" in headers)) {
1662
- headers["Content-Type"] = "application/json";
1663
- }
1664
- return headers;
1665
- }
1666
1841
  static normalizeUrl(url) {
1667
1842
  return url.replace(/\/+$/, "");
1668
1843
  }
1844
+ static resolveFetch(fetchFunction) {
1845
+ if (fetchFunction)
1846
+ return fetchFunction;
1847
+ if (typeof globalThis.fetch !== "function") {
1848
+ throw new Error("this runtime has no global fetch: pass one as SiloOptions.fetch");
1849
+ }
1850
+ return globalThis.fetch.bind(globalThis);
1851
+ }
1669
1852
  }
1670
1853
 
1671
1854
  // src/silo.ts
1672
1855
  class Silo {
1673
1856
  projects;
1674
1857
  media;
1858
+ cache;
1675
1859
  options;
1676
1860
  transport;
1677
1861
  constructor(options) {
1678
- this.options = options;
1679
- this.transport = new Transport({
1862
+ this.cache = options.cache instanceof SiloCache ? options.cache : new SiloCache(options.cache);
1863
+ this.options = { ...options, cache: this.cache };
1864
+ const fetchTransport = new FetchTransport({
1680
1865
  url: options.url,
1681
1866
  key: options.key,
1682
1867
  headers: options.headers,
1683
1868
  timeoutMilliseconds: options.timeoutMilliseconds,
1684
1869
  fetch: options.fetch
1685
1870
  });
1871
+ this.transport = this.cache.wrap(fetchTransport);
1686
1872
  this.projects = new Projects(this.transport);
1687
1873
  this.media = new Media(this.transport);
1688
1874
  }
@@ -1700,7 +1886,8 @@ class Silo {
1700
1886
  method: "GET",
1701
1887
  path: ApiPath.health(),
1702
1888
  signal: options?.signal,
1703
- timeoutMilliseconds: options?.timeoutMilliseconds
1889
+ timeoutMilliseconds: options?.timeoutMilliseconds,
1890
+ cache: "bypass"
1704
1891
  });
1705
1892
  }
1706
1893
  withKey(key) {
@@ -1750,6 +1937,7 @@ class RouteInventory {
1750
1937
  "PATCH /api/media/:id",
1751
1938
  "DELETE /api/media/:id",
1752
1939
  "GET /api/media/:id/usages",
1940
+ "POST /api/media/:id/content",
1753
1941
  "POST /api/media/delete",
1754
1942
  "GET /api/media/folders",
1755
1943
  "POST /api/media/folders",
@@ -1760,6 +1948,7 @@ class RouteInventory {
1760
1948
  "GET /api/session": "key introspection, an operator surface",
1761
1949
  "GET /api/keys": "keys and claims are an operator surface",
1762
1950
  "POST /api/keys": "keys and claims are an operator surface",
1951
+ "PATCH /api/keys/:id": "keys and claims are an operator surface",
1763
1952
  "DELETE /api/keys/:id": "keys and claims are an operator surface",
1764
1953
  "GET /api/audit": "an operator surface",
1765
1954
  "GET /api/observability": "an operator surface",
@@ -1881,6 +2070,7 @@ export {
1881
2070
  SortTerm,
1882
2071
  Sort,
1883
2072
  SiloError,
2073
+ SiloCache,
1884
2074
  Silo,
1885
2075
  SearchReach,
1886
2076
  SearchPage,
@@ -1,6 +1,7 @@
1
1
  import type { RequestOptions } from "../request-options.cjs";
2
2
  import type { Transport } from "../transport/transport.cjs";
3
3
  import type { MediaDeleteOptions } from "./media-delete-options.cjs";
4
+ import type { MediaReplace, MediaReplaceOptions } from "./media-replace.cjs";
4
5
  import type { MediaUsageQuery } from "./media-usage-page.cjs";
5
6
  import { MediaUsagePage } from "./media-usage-page.cjs";
6
7
  /** `MediaAsset`'s mapped state — `MediaAssetMapper.toRecord`'s output. */
@@ -53,6 +54,22 @@ export declare class MediaAsset {
53
54
  moveTo(folder: string, options?: RequestOptions): Promise<this>;
54
55
  /** REPLACES the tag list — `PATCH` replaces, it does not append. */
55
56
  setTags(tags: readonly string[], options?: RequestOptions): Promise<this>;
57
+ /**
58
+ * Swaps the bytes behind this asset (D67). `id`, `reference`, `url`,
59
+ * `filename` and `folder` all survive; `hash`, `sizeInBytes` and
60
+ * `contentType` are re-read from the server's answer.
61
+ *
62
+ * Every entry referencing it now resolves to the new file, without any of
63
+ * them being rewritten — which is the point, and the reason the server asks
64
+ * for `media:replace` **and** `entries:update` at every scope that refers
65
+ * to it.
66
+ *
67
+ * The new file must keep the current extension: the blob key's suffix is
68
+ * derived from the filename at upload and is the visible tail of the URL on
69
+ * a bucket-backed instance, so a `.png` asset takes a `.png` replacement.
70
+ * Converting a file is a new upload, not a replacement of this one.
71
+ */
72
+ replace(input: MediaReplace, options?: MediaReplaceOptions): Promise<this>;
56
73
  /** Refused while an entry references it; `{ force: true }` also needs
57
74
  * `entries:update` on every scope this asset reaches. */
58
75
  delete(options?: MediaDeleteOptions): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import type { RequestOptions } from "../request-options.js";
2
2
  import type { Transport } from "../transport/transport.js";
3
3
  import type { MediaDeleteOptions } from "./media-delete-options.js";
4
+ import type { MediaReplace, MediaReplaceOptions } from "./media-replace.js";
4
5
  import type { MediaUsageQuery } from "./media-usage-page.js";
5
6
  import { MediaUsagePage } from "./media-usage-page.js";
6
7
  /** `MediaAsset`'s mapped state — `MediaAssetMapper.toRecord`'s output. */
@@ -53,6 +54,22 @@ export declare class MediaAsset {
53
54
  moveTo(folder: string, options?: RequestOptions): Promise<this>;
54
55
  /** REPLACES the tag list — `PATCH` replaces, it does not append. */
55
56
  setTags(tags: readonly string[], options?: RequestOptions): Promise<this>;
57
+ /**
58
+ * Swaps the bytes behind this asset (D67). `id`, `reference`, `url`,
59
+ * `filename` and `folder` all survive; `hash`, `sizeInBytes` and
60
+ * `contentType` are re-read from the server's answer.
61
+ *
62
+ * Every entry referencing it now resolves to the new file, without any of
63
+ * them being rewritten — which is the point, and the reason the server asks
64
+ * for `media:replace` **and** `entries:update` at every scope that refers
65
+ * to it.
66
+ *
67
+ * The new file must keep the current extension: the blob key's suffix is
68
+ * derived from the filename at upload and is the visible tail of the URL on
69
+ * a bucket-backed instance, so a `.png` asset takes a `.png` replacement.
70
+ * Converting a file is a new upload, not a replacement of this one.
71
+ */
72
+ replace(input: MediaReplace, options?: MediaReplaceOptions): Promise<this>;
56
73
  /** Refused while an entry references it; `{ force: true }` also needs
57
74
  * `entries:update` on every scope this asset reaches. */
58
75
  delete(options?: MediaDeleteOptions): Promise<void>;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Turning what a caller passed into the two things a multipart part needs: a
3
+ * `Blob` and a filename.
4
+ *
5
+ * Shared by `Media.upload` and `MediaAsset.replace` (D67) rather than copied
6
+ * into the second. The filename is not cosmetic on either path — the server
7
+ * reads the extension off it to decide what the library accepts, and on a
8
+ * replace to decide whether the file's type is changing at all — so the two
9
+ * entry points must derive it the same way.
10
+ */
11
+ export declare class MediaFile {
12
+ static toBlob(bytes: Uint8Array | ArrayBuffer | Blob, contentType?: string): Blob;
13
+ /** A `File` carries its own name; a bare `Blob` does not, and sending it
14
+ * as "blob" is worse than refusing outright. */
15
+ static nameOf(input: Blob, explicit?: string): string;
16
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Turning what a caller passed into the two things a multipart part needs: a
3
+ * `Blob` and a filename.
4
+ *
5
+ * Shared by `Media.upload` and `MediaAsset.replace` (D67) rather than copied
6
+ * into the second. The filename is not cosmetic on either path — the server
7
+ * reads the extension off it to decide what the library accepts, and on a
8
+ * replace to decide whether the file's type is changing at all — so the two
9
+ * entry points must derive it the same way.
10
+ */
11
+ export declare class MediaFile {
12
+ static toBlob(bytes: Uint8Array | ArrayBuffer | Blob, contentType?: string): Blob;
13
+ /** A `File` carries its own name; a bare `Blob` does not, and sending it
14
+ * as "blob" is worse than refusing outright. */
15
+ static nameOf(input: Blob, explicit?: string): string;
16
+ }
@@ -9,6 +9,7 @@ import { MediaAsset } from "./media-asset.cjs";
9
9
  export declare class MediaPage extends Page<MediaAsset> {
10
10
  private readonly transport;
11
11
  private readonly wireQuery;
12
+ private readonly requestOptions;
12
13
  private constructor();
13
14
  get files(): readonly MediaAsset[];
14
15
  next(options?: RequestOptions): Promise<MediaPage | null>;
@@ -9,6 +9,7 @@ import { MediaAsset } from "./media-asset.js";
9
9
  export declare class MediaPage extends Page<MediaAsset> {
10
10
  private readonly transport;
11
11
  private readonly wireQuery;
12
+ private readonly requestOptions;
12
13
  private constructor();
13
14
  get files(): readonly MediaAsset[];
14
15
  next(options?: RequestOptions): Promise<MediaPage | null>;