@rankwrangler/cli 0.9.0 → 0.11.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/README.md CHANGED
@@ -7,18 +7,23 @@ npm install -g @rankwrangler/cli
7
7
 
8
8
  rw auth set ak_...
9
9
  rw product get B0DV53VS61
10
- rw product search "retro gardening shirt"
10
+ rw product get B0DV53VS61 --shortName
11
+ rw product search "retro gardening shirt" --maxBsr 300000
12
+ rw keyword results "retro gardening shirt"
11
13
  rw product history B0DV53VS61 --bucket week
12
14
  rw keyword get "retro gardening shirt"
13
15
  rw keyword search "retro gardening"
14
16
  rw keyword history "retro gardening shirt" --rangeDays 90
15
17
  ```
16
18
 
17
- The public command surface is Product `get/search/history`, keyword `get/search/history`, auth,
18
- config, version, and changelog. Product `get/history` and keyword reads wait for policy-current
19
- data and expose no refresh control; Product search retains its separate search contract. Search
20
- data contains `keyword`, `searchedAt`, and compact results with `organicSearchPlacement`. Output is
21
- a JSON `{ ok, data }` envelope; failures use `{ ok: false, error }` on stderr.
19
+ The public command surface is Product `get/search/history`, keyword `get/search/history/results`,
20
+ auth, config, version, and changelog. Product search reads the collected database immediately and
21
+ supports filters, sorting, and cursor pagination. Keyword results returns dated Amazon search
22
+ results with placement and accepts `--refresh` to replace the observed Search run.
23
+ Product `get/history` and other keyword reads use server-owned freshness policy.
24
+ Output is a JSON `{ ok, data }` envelope; failures use `{ ok: false, error }` on stderr.
22
25
  The CLI does not expose Catalog, Operation, provider status, or polling commands.
26
+ `--shortName` adds a nullable image-informed `listing.shortName` to a single Product get, waiting
27
+ up to 20 seconds for generation and returning `null` if it is still running.
23
28
 
24
29
  See the [CLI reference](../../docs/reference/cli.md) for options and release workflow.
package/dist/CHANGELOG.md CHANGED
@@ -2,6 +2,64 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## v0.11.0 - 2026-10-23
6
+
7
+ ### Changed
8
+
9
+ - Breaking: `product.search` is now a query over the collected catalog, with optional text,
10
+ structured filters, screening metrics, sorting and cursor pagination. It returns
11
+ `items`, `nextCursor` and dated keyword coverage without waiting for external collection.
12
+ - Preserve the previous external search capability as `keyword.results`, a mutation accepting
13
+ `term` and optional `refresh`, with dated Product placements in the existing response shape.
14
+ - Use the same catalog filtering and sorting in the dashboard, CLI and MCP. Dashboard filters
15
+ apply to the full stored catalog rather than only loaded pages.
16
+
17
+ ### Added
18
+
19
+ - Prepare native text-search indexes and a verified official-brand registry. Research search hides
20
+ known registry matches by default while retaining unknown brands. The registry starts empty.
21
+
22
+ - Add adaptive keyword checks, bounded SP-API pagination and dated collection coverage. General
23
+ searches record interest and return immediately; collection runs independently in the background.
24
+ - Protect Keepa capacity with durable request reservations, measured costs and separate work budgets.
25
+ Admit new recurring history selectively while preserving every existing Product's eligibility.
26
+ - Add operator collection status and active/admitted refresh counts.
27
+
28
+ ## v0.10.0 - 2026-10-20
29
+
30
+ ### Added
31
+
32
+ - Add optional `include` on `product.get` for `marketData`, `shortName`, and `cutoutThumbnail`.
33
+ The default remains `marketData`.
34
+ - Add image-informed design short names. A Product get that includes `shortName` waits up to 20
35
+ seconds. `rw product get <asin> --shortName` requests that include together with `marketData`.
36
+ The response carries `listing.shortName`, or `null` when no name is ready.
37
+ - Add on-demand transparent 128×128 cutout thumbnails through `cutoutThumbnail` on that same
38
+ `include` list. RankWrangler centers the subject and reuses the cutout until the listing image
39
+ changes.
40
+ - Advertise the website URL and a 128px icon in hosted MCP server info.
41
+ - Name the stale ingestion check on `GET /api/health` when the service is degraded, and include
42
+ the deployed commit as `revision` on both health responses.
43
+
44
+ ### Changed
45
+
46
+ - Breaking: replace `isUnavailable` with `amazonListingStatus` (`active` or `deleted`) on Product
47
+ listings, search results, and basic `getMany` results. A deleted listing keeps its last-known
48
+ title and thumbnail. `active` means the Amazon detail page exists. The dashboard marks deleted
49
+ listings.
50
+ - Return last-known `getMany` listings immediately and refresh stale listings in the background.
51
+ Short names and cutouts stay on single Product get.
52
+
53
+ ### Fixed
54
+
55
+ - Show the server error when the dashboard Products read fails, and reserve the empty catalog
56
+ state for a successful read with no rows.
57
+ - Load the API usage card across the UTC day boundary.
58
+ - Fail an Amazon Catalog read that does not start within 2 minutes with a retryable capacity
59
+ error.
60
+ - Require a second overdue Product before Keepa or SP-API freshness degrades `GET /api/health`,
61
+ and wait out a top search terms report that is still pending.
62
+
5
63
  ## v0.9.0 - 2026-08-09
6
64
 
7
65
  ### Added
@@ -1,8 +1,14 @@
1
1
  import { parseIntegerOption } from './cli-options.js';
2
2
  const KEYWORD_MARKETPLACE_ID = 'ATVPDKIKX0DER';
3
3
  export const runKeywordCommand = async (command, client, options, fail) => {
4
- if (options.refresh) {
5
- fail('INVALID_INPUT', '--refresh is not supported for keyword reads');
4
+ if (command.verb !== 'results' && options.refresh) {
5
+ fail('INVALID_INPUT', '--refresh is only supported for keyword results');
6
+ }
7
+ if (command.verb === 'results') {
8
+ return await client.keyword.results.mutate({
9
+ term: requireKeyword(command.args, fail, 'keyword results'),
10
+ refresh: Boolean(options.refresh),
11
+ });
6
12
  }
7
13
  if (command.verb === 'get') {
8
14
  return await runKeywordGet(command.args, client, fail);
@@ -2,6 +2,28 @@ import type { CliConfig } from './cli-config';
2
2
  import { type HistoryBucket, type HistoryMetricAlias } from './history-options';
3
3
  type CliFail = (code: string, message: string, details?: unknown) => never;
4
4
  export interface CliOptionValues {
5
+ textFields?: string;
6
+ excludeWord?: string[];
7
+ brand?: string[];
8
+ excludeBrand?: string[];
9
+ categoryId?: string[];
10
+ facet?: string[];
11
+ includeOfficialBrands?: boolean;
12
+ merch?: string;
13
+ listingStatus?: string;
14
+ sort?: string;
15
+ minBsr?: string;
16
+ maxBsr?: string;
17
+ minPrice?: string;
18
+ maxPrice?: string;
19
+ minMonthlySold?: string;
20
+ maxMonthlySold?: string;
21
+ updatedAfter?: string;
22
+ updatedBefore?: string;
23
+ availableAfter?: string;
24
+ availableBefore?: string;
25
+ discoveredAfter?: string;
26
+ discoveredBefore?: string;
5
27
  baseUrl?: string;
6
28
  marketplace?: string;
7
29
  metrics?: string;
@@ -13,6 +35,7 @@ export interface CliOptionValues {
13
35
  bucket?: string;
14
36
  cursor?: string;
15
37
  refresh?: boolean;
38
+ shortName?: boolean;
16
39
  help?: boolean;
17
40
  version?: boolean;
18
41
  }
@@ -1,13 +1,17 @@
1
+ import { runProductSearch } from './cli-product-search.js';
1
2
  import { parseIntegerOption, requireAsins, requireMarketplaceId, requireSingleAsin, resolveHistoryBucket, resolveHistoryMetrics, resolveHistoryWindow, } from './cli-options.js';
2
3
  export const runProductCommand = async (command, client, config, options, fail) => {
3
- if (command.verb !== 'search' && options.refresh) {
4
- fail('INVALID_INPUT', '--refresh is only supported for product search');
4
+ if (options.refresh) {
5
+ fail('INVALID_INPUT', '--refresh is only supported for keyword results');
6
+ }
7
+ if (command.verb !== 'get' && options.shortName) {
8
+ fail('INVALID_INPUT', '--shortName is only supported for product get');
5
9
  }
6
10
  if (command.verb === 'get') {
7
11
  return await runProductGet(command.args, client, config, options, fail);
8
12
  }
9
13
  if (command.verb === 'search') {
10
- return await runProductSearch(command.args, client, options, fail);
14
+ return await runProductSearch(command.args, client, config, options, fail);
11
15
  }
12
16
  if (command.verb === 'history') {
13
17
  return await runProductHistory(command.args, client, config, options, fail);
@@ -19,26 +23,20 @@ export const runProductCommand = async (command, client, config, options, fail)
19
23
  const runProductGet = async (args, client, config, options, fail) => {
20
24
  const marketplaceId = requireMarketplaceId(options, config);
21
25
  const asins = requireAsins(args, fail);
26
+ if (asins.length > 1 && options.shortName) {
27
+ fail('INVALID_INPUT', '--shortName requires exactly one ASIN');
28
+ }
22
29
  if (asins.length === 1) {
23
30
  return await client.product.get.mutate({
24
31
  marketplaceId,
25
32
  asin: asins[0],
33
+ ...(options.shortName ? { include: ['marketData', 'shortName'] } : {}),
26
34
  });
27
35
  }
28
36
  return await client.product.getMany.mutate({
29
37
  products: asins.map(asin => ({ marketplaceId, asin })),
30
38
  });
31
39
  };
32
- const runProductSearch = async (args, client, options, fail) => {
33
- const term = args.join(' ').trim();
34
- if (!term) {
35
- fail('INVALID_INPUT', 'product search requires a keyword');
36
- }
37
- return await client.product.search.mutate({
38
- term,
39
- refresh: Boolean(options.refresh),
40
- });
41
- };
42
40
  const runProductHistory = async (args, client, config, options, fail) => {
43
41
  const asin = requireSingleAsin(args, fail, 'product history');
44
42
  const historyOptions = resolveHistoryOptions(options, fail);
@@ -0,0 +1,6 @@
1
+ import type { createRankWranglerClient } from '@rankwrangler/http-client';
2
+ import type { CliConfig } from './cli-config';
3
+ import { type CliOptionValues } from './cli-options';
4
+ type CliFail = (code: string, message: string, details?: unknown) => never;
5
+ export declare const runProductSearch: (args: string[], client: ReturnType<typeof createRankWranglerClient>, config: CliConfig, options: CliOptionValues, fail: CliFail) => Promise<unknown>;
6
+ export {};
@@ -0,0 +1,99 @@
1
+ import { requireMarketplaceId } from './cli-options.js';
2
+ export const runProductSearch = async (args, client, config, options, fail) => {
3
+ const term = args.join(' ').trim();
4
+ const merch = choice(options.merch, ['true', 'false', 'unknown', 'all'], 'merch', fail);
5
+ return await client.product.search.query({
6
+ ...(term ? { term } : {}),
7
+ marketplaceIds: [requireMarketplaceId(options, config)],
8
+ textFields: choice(options.textFields, ['all', 'title'], 'textFields', fail),
9
+ excludeWords: options.excludeWord,
10
+ brands: options.brand,
11
+ excludeBrands: options.excludeBrand,
12
+ categoryIds: options.categoryId?.map(value => integer(value, 'categoryId', 1, fail)),
13
+ facets: options.facet?.map(value => {
14
+ const separator = value.indexOf('=');
15
+ if (separator < 1 || separator === value.length - 1) {
16
+ fail('INVALID_INPUT', '--facet must be facet=name');
17
+ }
18
+ return {
19
+ facet: value.slice(0, separator),
20
+ name: value.slice(separator + 1),
21
+ };
22
+ }),
23
+ includeOfficialBrands: options.includeOfficialBrands,
24
+ isMerchListing: merch === undefined
25
+ ? undefined
26
+ : merch === 'unknown' || merch === 'all'
27
+ ? merch
28
+ : merch === 'true',
29
+ amazonListingStatus: choice(options.listingStatus, ['active', 'deleted', 'all'], 'listingStatus', fail),
30
+ salesRank: range(options.minBsr, options.maxBsr, 'Bsr', fail),
31
+ price: range(options.minPrice, options.maxPrice, 'Price', fail),
32
+ monthlySold: range(options.minMonthlySold, options.maxMonthlySold, 'MonthlySold', fail),
33
+ firstAvailableAt: dates(options.availableAfter, options.availableBefore, 'available', fail),
34
+ updatedAt: dates(options.updatedAfter, options.updatedBefore, 'updated', fail),
35
+ discoveredAt: dates(options.discoveredAfter, options.discoveredBefore, 'discovered', fail),
36
+ sort: choice(options.sort, [
37
+ 'relevance',
38
+ 'salesRankAsc',
39
+ 'salesRankDesc',
40
+ 'firstAvailableAtDesc',
41
+ 'firstAvailableAtAsc',
42
+ 'updatedAtAsc',
43
+ 'updatedAtDesc',
44
+ 'discoveredAtDesc',
45
+ 'priceAsc',
46
+ 'priceDesc',
47
+ 'monthlySoldDesc',
48
+ ], 'sort', fail),
49
+ limit: options.limit === undefined ? undefined : integer(options.limit, 'limit', 1, fail, 100),
50
+ cursor: options.cursor,
51
+ });
52
+ };
53
+ const choice = (value, choices, name, fail) => {
54
+ if (value === undefined)
55
+ return undefined;
56
+ const match = choices.find(candidate => candidate === value);
57
+ if (match === undefined)
58
+ fail('INVALID_INPUT', `--${name} must be ${choices.join(', ')}`);
59
+ return match;
60
+ };
61
+ const integer = (value, name, min, fail, max = Number.MAX_SAFE_INTEGER) => {
62
+ const parsed = Number(value);
63
+ if (!value.trim() || !Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
64
+ fail('INVALID_INPUT', `--${name} must be an integer between ${min} and ${max}`);
65
+ }
66
+ return parsed;
67
+ };
68
+ const range = (min, max, name, fail) => {
69
+ if (min === undefined && max === undefined)
70
+ return undefined;
71
+ const bounds = {
72
+ ...(min === undefined ? {} : { min: integer(min, `min${name}`, 0, fail) }),
73
+ ...(max === undefined ? {} : { max: integer(max, `max${name}`, 0, fail) }),
74
+ };
75
+ if (bounds.min !== undefined && bounds.max !== undefined && bounds.min > bounds.max) {
76
+ fail('INVALID_INPUT', `--min${name} must not exceed --max${name}`);
77
+ }
78
+ return bounds;
79
+ };
80
+ const dates = (after, before, name, fail) => {
81
+ if (after === undefined && before === undefined)
82
+ return undefined;
83
+ const parse = (value) => {
84
+ if (!/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?Z)?$/.test(value) ||
85
+ !Number.isFinite(new Date(value).getTime()) ||
86
+ new Date(value).toISOString().slice(0, 10) !== value.slice(0, 10)) {
87
+ fail('INVALID_INPUT', `--${name}After and --${name}Before require ISO dates`);
88
+ }
89
+ return new Date(value).toISOString();
90
+ };
91
+ const bounds = {
92
+ after: after === undefined ? undefined : parse(after),
93
+ before: before === undefined ? undefined : parse(before),
94
+ };
95
+ if (bounds.after && bounds.before && bounds.after > bounds.before) {
96
+ fail('INVALID_INPUT', `--${name}After must not exceed --${name}Before`);
97
+ }
98
+ return bounds;
99
+ };
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ const SUPPORTED_COMMANDS = new Set([
17
17
  'keyword:get',
18
18
  'keyword:search',
19
19
  'keyword:history',
20
+ 'keyword:results',
20
21
  'auth:status',
21
22
  'auth:set',
22
23
  'auth:clear',
@@ -42,6 +43,29 @@ const { positionals, values } = parseArgs({
42
43
  bucket: { type: 'string' },
43
44
  cursor: { type: 'string' },
44
45
  refresh: { type: 'boolean' },
46
+ shortName: { type: 'boolean' },
47
+ textFields: { type: 'string' },
48
+ excludeWord: { type: 'string', multiple: true },
49
+ brand: { type: 'string', multiple: true },
50
+ excludeBrand: { type: 'string', multiple: true },
51
+ categoryId: { type: 'string', multiple: true },
52
+ facet: { type: 'string', multiple: true },
53
+ includeOfficialBrands: { type: 'boolean' },
54
+ merch: { type: 'string' },
55
+ listingStatus: { type: 'string' },
56
+ sort: { type: 'string' },
57
+ minBsr: { type: 'string' },
58
+ maxBsr: { type: 'string' },
59
+ minPrice: { type: 'string' },
60
+ maxPrice: { type: 'string' },
61
+ minMonthlySold: { type: 'string' },
62
+ maxMonthlySold: { type: 'string' },
63
+ updatedAfter: { type: 'string' },
64
+ updatedBefore: { type: 'string' },
65
+ availableAfter: { type: 'string' },
66
+ availableBefore: { type: 'string' },
67
+ discoveredAfter: { type: 'string' },
68
+ discoveredBefore: { type: 'string' },
45
69
  stdin: { type: 'boolean' },
46
70
  },
47
71
  allowPositionals: true,
@@ -121,7 +145,9 @@ const resolveCommandOrFail = (inputPositionals) => {
121
145
  if (command) {
122
146
  return command;
123
147
  }
124
- fail('UNKNOWN_COMMAND', 'Unknown command', { command: inputPositionals.join(' ') });
148
+ fail('UNKNOWN_COMMAND', 'Unknown command', {
149
+ command: inputPositionals.join(' '),
150
+ });
125
151
  throw new Error('unreachable');
126
152
  };
127
153
  const isSupportedCommand = (command) => SUPPORTED_COMMANDS.has(`${command.resource}:${command.verb}`);
package/dist/usage.js CHANGED
@@ -16,11 +16,12 @@ export const printUsage = (paths) => {
16
16
  'COMMANDS',
17
17
  ' changelog',
18
18
  ' product get <ASIN...>',
19
- ' product search <keyword>',
19
+ ' product search [text]',
20
20
  ' product history <ASIN>',
21
21
  ' keyword get <keyword>',
22
22
  ' keyword search <text>',
23
23
  ' keyword history <keyword>',
24
+ ' keyword results <keyword>',
24
25
  ' auth status',
25
26
  ' auth set [apiKey]',
26
27
  ' auth set --stdin',
@@ -38,7 +39,8 @@ export const printUsage = (paths) => {
38
39
  ' --version Print the CLI version',
39
40
  ' --baseUrl <origin> Override API origin',
40
41
  ` -m, --marketplace <id> Override marketplace (default: ${DEFAULT_MARKETPLACE_ID})`,
41
- ' --refresh Product search refresh (separate Search contract)',
42
+ ' --refresh Replace the Search run for keyword results',
43
+ ' --shortName Generate a design name for one Merch Product',
42
44
  ' --metrics <list> Product history metrics: salesRank,price (default: salesRank,price)',
43
45
  ' --bucket <unit> Product history buckets: auto,day,week,month (default: auto)',
44
46
  ' --days <N> Product history lookback (30-3650, default: 365)',
@@ -46,7 +48,22 @@ export const printUsage = (paths) => {
46
48
  ' --startAt <ISO> Product history range start (ISO date/time)',
47
49
  ' --endAt <ISO> Product history range end (ISO date/time)',
48
50
  ' --limit <N> Result limit',
49
- ' --cursor <N> Keyword search offset (default: 0)',
51
+ ' --cursor <value> Product search cursor or keyword search offset',
52
+ ' --sort <order> Product search order (default: relevance)',
53
+ ' --textFields <scope> Product search text: all,title (default: all)',
54
+ ' --minBsr/--maxBsr <N> Product sales-rank bounds',
55
+ ' --minPrice/--maxPrice <N> Product price bounds in minor currency units',
56
+ ' --minMonthlySold/--maxMonthlySold <N> Product demand bounds',
57
+ ' --availableAfter/--availableBefore <ISO> First-available bounds',
58
+ ' --discoveredAfter/--discoveredBefore <ISO> Discovery bounds',
59
+ ' --updatedAfter/--updatedBefore <ISO> Product update bounds',
60
+ ' --brand/--excludeBrand <name> Repeat for brand filters',
61
+ ' --excludeWord <word> Repeat for excluded words',
62
+ ' --categoryId <N> Repeat for category filters',
63
+ ' --facet <facet=name> Repeat for facet filters',
64
+ ' --merch <value> true,false,unknown,all (default: true)',
65
+ ' --listingStatus <value> active,deleted,all (default: active)',
66
+ ' --includeOfficialBrands Include known official brands in Product search',
50
67
  ' --stdin Read auth secret from standard input',
51
68
  '',
52
69
  'FILES',
@@ -65,7 +82,8 @@ export const printUsage = (paths) => {
65
82
  ' printf %s "$MERCHBASE_API_KEY" | rw auth set --stdin',
66
83
  ` ${API_KEY_ENV_VAR}=ak_... rw product get B0DV53VS61`,
67
84
  ' rw product get B0DV53VS61 B012345678',
68
- ' rw product search "retro gardening shirt"',
85
+ ' rw product search "retro gardening shirt" --maxBsr 300000 --sort salesRankAsc',
86
+ ' rw keyword results "retro gardening shirt"',
69
87
  ' rw product history B0DV53VS61 --bucket week',
70
88
  ' rw keyword get "retro gardening shirt"',
71
89
  ' rw keyword search "retro gardening" --limit 10',
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@rankwrangler/cli",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Official RankWrangler command line interface",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/merchbaseco/rankwrangler.git",
8
+ "directory": "packages/cli"
9
+ },
5
10
  "type": "module",
6
11
  "bin": {
7
12
  "rw": "dist/index.js",
@@ -24,8 +29,8 @@
24
29
  "provenance": true
25
30
  },
26
31
  "dependencies": {
27
- "@merchbaseco/access": "0.3.0",
28
- "@rankwrangler/http-client": "^0.9.0",
32
+ "@merchbaseco/access": "0.4.0",
33
+ "@rankwrangler/http-client": "^0.11.0",
29
34
  "keytar": "7.9.0"
30
35
  },
31
36
  "devDependencies": {
@@ -1,41 +0,0 @@
1
- export declare const HISTORY_METRIC_ALIASES: readonly ["bsr", "price"];
2
- export type HistoryMetricAlias = (typeof HISTORY_METRIC_ALIASES)[number];
3
- type HistoryPointTuple = [string, number | null] | [string, null, 1];
4
- export type AgentHistorySeries = {
5
- bsr?: {
6
- unit: 'rank';
7
- category: {
8
- id: number;
9
- name: string | null;
10
- } | null;
11
- points: HistoryPointTuple[];
12
- };
13
- price?: {
14
- unit: 'minorCurrency';
15
- currencyCode: 'USD';
16
- valueScale: 100;
17
- points: HistoryPointTuple[];
18
- };
19
- };
20
- export type AgentHistoryResponse = {
21
- schemaVersion?: number;
22
- status?: string;
23
- syncTriggered: boolean;
24
- latestImportAt: string | null;
25
- series?: AgentHistorySeries;
26
- };
27
- export declare const buildCliHistoryResponse: ({ asin, marketplaceId, metrics, response, }: {
28
- asin: string;
29
- marketplaceId: string;
30
- metrics: HistoryMetricAlias[];
31
- response: AgentHistoryResponse;
32
- }) => {
33
- schemaVersion: number;
34
- asin: string;
35
- marketplaceId: string;
36
- status: string;
37
- latestImportAt: string | null;
38
- syncTriggered: boolean;
39
- series: AgentHistorySeries;
40
- };
41
- export {};
@@ -1,26 +0,0 @@
1
- export const HISTORY_METRIC_ALIASES = ['bsr', 'price'];
2
- export const buildCliHistoryResponse = ({ asin, marketplaceId, metrics, response, }) => {
3
- const series = {
4
- ...(metrics.includes('bsr') && response.series?.bsr ? { bsr: response.series.bsr } : {}),
5
- ...(metrics.includes('price') && response.series?.price
6
- ? { price: response.series.price }
7
- : {}),
8
- };
9
- const hasAnyPoints = Object.values(series).some(metricSeries => metricSeries.points.length > 0);
10
- const status = normalizeHistoryStatus(response.status);
11
- return {
12
- schemaVersion: response.schemaVersion ?? 1,
13
- asin,
14
- marketplaceId,
15
- status: status ?? (response.syncTriggered ? 'collecting' : hasAnyPoints ? 'ready' : 'empty'),
16
- latestImportAt: response.latestImportAt,
17
- syncTriggered: response.syncTriggered,
18
- series,
19
- };
20
- };
21
- const normalizeHistoryStatus = (status) => {
22
- if (status === 'ready' || status === 'collecting' || status === 'empty') {
23
- return status;
24
- }
25
- return null;
26
- };