@devicecloud.dev/dcd 5.7.1 → 5.8.0-beta.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.
@@ -1,7 +1,7 @@
1
1
  import { TAppMetadata } from '../types.js';
2
2
  import type { AuthContext } from '../types/domain/auth.types.js';
3
3
  import type { LiveCommandStatus, LiveExecResult, LiveSession, LiveSessionSummary } from '../types/domain/live.types.js';
4
- import { components } from '../types/generated/schema.types.js';
4
+ import { components, paths } from '../types/generated/schema.types.js';
5
5
  /**
6
6
  * Error thrown for non-OK API responses, carrying the HTTP status so callers
7
7
  * can branch on auth failures etc. without string matching.
@@ -10,6 +10,17 @@ export declare class ApiError extends Error {
10
10
  status: number;
11
11
  constructor(message: string, status: number);
12
12
  }
13
+ type ResultsResponse = paths['/results/{uploadId}']['get']['responses']['200']['content']['application/json'];
14
+ /**
15
+ * The last results body a poll loop received and the ETag it came with, so
16
+ * the next poll can revalidate with `If-None-Match` and get an empty 304 back
17
+ * when nothing changed. Create one per poll loop; `getResultsForUpload` fills
18
+ * and clears it.
19
+ */
20
+ export interface ResultsPollCache {
21
+ body?: ResultsResponse;
22
+ etag?: string;
23
+ }
13
24
  export declare const ApiGateway: {
14
25
  /**
15
26
  * Enhances generic "fetch failed" errors with more specific diagnostic information
@@ -98,7 +109,23 @@ export declare const ApiGateway: {
98
109
  token?: string;
99
110
  }>;
100
111
  finishLargeFile(baseUrl: string, auth: AuthContext, fileId: string, partSha1Array: string[]): Promise<unknown>;
101
- getResultsForUpload(baseUrl: string, auth: AuthContext, uploadId: string): Promise<{
112
+ /**
113
+ * Fetch the result rows for an upload, for the `dcd cloud` poll loop.
114
+ *
115
+ * Asks for `?view=summary`, which leaves out `result_files` and the rest of
116
+ * the columns the CLI never reads. An API that predates the view ignores
117
+ * the parameter and returns the full rows, a superset, so this works
118
+ * against either.
119
+ *
120
+ * With a `cache`, a repeat poll revalidates with `If-None-Match` and a 304
121
+ * hands back the cached body.
122
+ * @param baseUrl API base URL
123
+ * @param auth AuthContext carrying request headers
124
+ * @param uploadId Upload ID to fetch results for
125
+ * @param cache Optional per-poll-loop cache, filled and cleared here
126
+ * @returns The results response body
127
+ */
128
+ getResultsForUpload(baseUrl: string, auth: AuthContext, uploadId: string, cache?: ResultsPollCache): Promise<{
102
129
  statusCode?: number;
103
130
  results?: components["schemas"]["TResultResponse"][];
104
131
  }>;
@@ -209,3 +236,4 @@ export declare const ApiGateway: {
209
236
  stopLiveSession(baseUrl: string, auth: AuthContext, sessionName: string): Promise<void>;
210
237
  getLiveSession(baseUrl: string, auth: AuthContext, sessionName: string): Promise<LiveSession>;
211
238
  };
239
+ export {};
@@ -347,20 +347,73 @@ export const ApiGateway = {
347
347
  throw error;
348
348
  }
349
349
  },
350
- async getResultsForUpload(baseUrl, auth, uploadId) {
350
+ /**
351
+ * Fetch the result rows for an upload, for the `dcd cloud` poll loop.
352
+ *
353
+ * Asks for `?view=summary`, which leaves out `result_files` and the rest of
354
+ * the columns the CLI never reads. An API that predates the view ignores
355
+ * the parameter and returns the full rows, a superset, so this works
356
+ * against either.
357
+ *
358
+ * With a `cache`, a repeat poll revalidates with `If-None-Match` and a 304
359
+ * hands back the cached body.
360
+ * @param baseUrl API base URL
361
+ * @param auth AuthContext carrying request headers
362
+ * @param uploadId Upload ID to fetch results for
363
+ * @param cache Optional per-poll-loop cache, filled and cleared here
364
+ * @returns The results response body
365
+ */
366
+ async getResultsForUpload(baseUrl, auth, uploadId, cache) {
351
367
  // TODO: merge with getUploadStatus
368
+ const url = `${baseUrl}/results/${uploadId}?view=summary`;
352
369
  try {
353
- const res = await fetch(`${baseUrl}/results/${uploadId}`, {
354
- headers: { ...auth.headers },
355
- });
370
+ const headers = { ...auth.headers };
371
+ if (cache?.body && cache.etag) {
372
+ headers['if-none-match'] = cache.etag;
373
+ // Per the Fetch spec, undici appends `Cache-Control: no-cache` to any
374
+ // request that carries a conditional header and no Cache-Control of
375
+ // its own, and Express's `fresh` never answers 304 to a no-cache
376
+ // request. So every poll would get a full 200. max-age=0 still asks
377
+ // for revalidation, and keeps undici from adding no-cache.
378
+ headers['cache-control'] = 'max-age=0';
379
+ }
380
+ const res = await fetch(url, { headers });
381
+ // Before the `!res.ok` check, since a 304 is not `ok`.
382
+ if (res.status === 304) {
383
+ if (cache?.body) {
384
+ return cache.body;
385
+ }
386
+ // We sent no If-None-Match, so there is nothing to reuse. Start over
387
+ // with an unconditional request on the next poll.
388
+ if (cache) {
389
+ delete cache.body;
390
+ delete cache.etag;
391
+ }
392
+ throw new Error('Failed to get results: API returned 304 Not Modified to an unconditional request');
393
+ }
356
394
  if (!res.ok) {
357
395
  await this.handleApiError(res, 'Failed to get results');
358
396
  }
359
- return await parseJsonResponse(res, 'Failed to get results');
397
+ const body = await parseJsonResponse(res, 'Failed to get results');
398
+ if (cache) {
399
+ // Only pin a real result set. The API reports some errors as a 200
400
+ // with a `{ statusCode, message }` envelope, and an empty set is
401
+ // rejected by the poll loop; caching either would replay it on 304.
402
+ const etag = res.headers.get('etag');
403
+ if (etag && Array.isArray(body.results) && body.results.length > 0) {
404
+ cache.body = body;
405
+ cache.etag = etag;
406
+ }
407
+ else {
408
+ delete cache.body;
409
+ delete cache.etag;
410
+ }
411
+ }
412
+ return body;
360
413
  }
361
414
  catch (error) {
362
415
  if (error instanceof TypeError && error.message === 'fetch failed') {
363
- throw this.enhanceFetchError(error, `${baseUrl}/results/${uploadId}`);
416
+ throw this.enhanceFetchError(error, url);
364
417
  }
365
418
  throw error;
366
419
  }
@@ -118,6 +118,7 @@ export declare class ResultsPollingService {
118
118
  * @param apiUrl API base URL
119
119
  * @param auth AuthContext carrying request headers
120
120
  * @param uploadId Upload ID to fetch results for
121
+ * @param cache The poll loop's results cache, for ETag revalidation
121
122
  * @param debug Whether debug logging is enabled
122
123
  * @param logger Optional logger function
123
124
  * @returns Promise resolving to test results
@@ -133,6 +133,9 @@ export class ResultsPollingService {
133
133
  let { auth } = options;
134
134
  this.initializePollingDisplay(json, logger);
135
135
  let sequentialPollFailures = 0;
136
+ // The last result set and its ETag, so a poll that finds nothing changed
137
+ // gets an empty 304 instead of the whole set again.
138
+ const resultsCache = {};
136
139
  const pollIntervalMs = auth.mode === 'bearer'
137
140
  ? this.BEARER_POLL_INTERVAL_MS
138
141
  : this.APIKEY_POLL_INTERVAL_MS;
@@ -229,7 +232,7 @@ export class ResultsPollingService {
229
232
  nextPollAt = null;
230
233
  renderStatus();
231
234
  auth = await this.refreshPollingAuth(auth, uploadId, subscription, debug, logger);
232
- const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger);
235
+ const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, resultsCache, debug, logger);
233
236
  const { summary } = this.calculateStatusSummary(updatedResults);
234
237
  if (!json) {
235
238
  statusBody = this.buildStatusBody(updatedResults, quiet, summary);
@@ -395,21 +398,28 @@ export class ResultsPollingService {
395
398
  * @param apiUrl API base URL
396
399
  * @param auth AuthContext carrying request headers
397
400
  * @param uploadId Upload ID to fetch results for
401
+ * @param cache The poll loop's results cache, for ETag revalidation
398
402
  * @param debug Whether debug logging is enabled
399
403
  * @param logger Optional logger function
400
404
  * @returns Promise resolving to test results
401
405
  */
402
- async fetchAndLogResults(apiUrl, auth, uploadId, debug, logger) {
406
+ async fetchAndLogResults(apiUrl, auth, uploadId, cache, debug, logger) {
403
407
  if (debug && logger) {
404
408
  logger(`[DEBUG] Polling for results: ${uploadId}`);
405
409
  }
406
- const { results: updatedResults } = await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId);
410
+ // A 304 hands back the cached body itself; a 200 is always a new object.
411
+ const cachedBody = cache.body;
412
+ const response = await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId, cache);
413
+ const notModified = cachedBody !== undefined && response === cachedBody;
414
+ const { results: updatedResults } = response;
407
415
  // An empty array would otherwise read as "all complete, all passed".
408
416
  if (!updatedResults || updatedResults.length === 0) {
409
417
  throw new Error('no results');
410
418
  }
411
419
  if (debug && logger) {
412
- logger(`[DEBUG] Poll received ${updatedResults.length} results`);
420
+ logger(notModified
421
+ ? `[DEBUG] Poll not modified (304), reusing ${updatedResults.length} cached results`
422
+ : `[DEBUG] Poll received ${updatedResults.length} results`);
413
423
  for (const result of updatedResults) {
414
424
  logger(`[DEBUG] Result status: ${result.test_file_name} - ${result.status}`);
415
425
  }
package/package.json CHANGED
@@ -68,7 +68,7 @@
68
68
  "test:unit": "node scripts/test-runner.mjs --unit",
69
69
  "typecheck": "tsc --noEmit -p tsconfig.test.json"
70
70
  },
71
- "version": "5.7.1",
71
+ "version": "5.8.0-beta.1",
72
72
  "bugs": {
73
73
  "url": "https://discord.gg/gm3mJwcNw8"
74
74
  },