adapty 0.8.7 → 0.8.8

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 (86) hide show
  1. package/README.md +30 -2
  2. package/dist/cli/base/adapty/adapty-command.d.ts +1 -4
  3. package/dist/cli/base/adapty/build.d.ts +2 -8
  4. package/dist/cli/base/adapty/index.d.ts +2 -3
  5. package/dist/cli/base/adapty/index.js +1 -1
  6. package/dist/cli/base/adapty/openSession.d.ts +2 -19
  7. package/dist/cli/base/adapty/openSession.js +6 -16
  8. package/dist/cli/base/attribution/attribution-command.d.ts +16 -0
  9. package/dist/cli/base/attribution/attribution-command.js +42 -0
  10. package/dist/cli/base/attribution/build.d.ts +5 -0
  11. package/dist/cli/base/attribution/build.js +12 -0
  12. package/dist/cli/base/attribution/flags.d.ts +16 -0
  13. package/dist/cli/base/attribution/flags.js +28 -0
  14. package/dist/cli/base/attribution/index.d.ts +4 -0
  15. package/dist/cli/base/attribution/index.js +4 -0
  16. package/dist/cli/base/attribution/openSession.d.ts +9 -0
  17. package/dist/cli/base/attribution/openSession.js +19 -0
  18. package/dist/cli/base/base-command.d.ts +7 -0
  19. package/dist/cli/base/session.d.ts +30 -0
  20. package/dist/cli/base/session.js +20 -0
  21. package/dist/cli/commands/attribution/dimensions.d.ts +7 -0
  22. package/dist/cli/commands/attribution/dimensions.js +18 -0
  23. package/dist/cli/commands/attribution/metrics.d.ts +7 -0
  24. package/dist/cli/commands/attribution/metrics.js +35 -0
  25. package/dist/cli/commands/attribution/report/index.d.ts +18 -0
  26. package/dist/cli/commands/attribution/report/index.js +93 -0
  27. package/dist/cli/commands/attribution/report/lib/render.d.ts +3 -0
  28. package/dist/cli/commands/attribution/report/lib/render.js +24 -0
  29. package/dist/cli/commands/attribution/values.d.ts +14 -0
  30. package/dist/cli/commands/attribution/values.js +44 -0
  31. package/dist/cli/errors.d.ts +12 -0
  32. package/dist/cli/errors.js +16 -0
  33. package/dist/cli/flags.d.ts +4 -0
  34. package/dist/cli/flags.js +14 -4
  35. package/dist/commands/access-levels/create.js +2 -2
  36. package/dist/commands/access-levels/get.js +3 -2
  37. package/dist/commands/access-levels/list.js +3 -2
  38. package/dist/commands/access-levels/update.js +3 -2
  39. package/dist/commands/attribution/dimensions.d.ts +1 -0
  40. package/dist/commands/attribution/dimensions.js +2 -0
  41. package/dist/commands/attribution/metrics.d.ts +1 -0
  42. package/dist/commands/attribution/metrics.js +2 -0
  43. package/dist/commands/attribution/report.d.ts +1 -0
  44. package/dist/commands/attribution/report.js +2 -0
  45. package/dist/commands/attribution/values.d.ts +1 -0
  46. package/dist/commands/attribution/values.js +2 -0
  47. package/dist/commands/flows/config/get.js +3 -2
  48. package/dist/commands/flows/config/update.js +3 -2
  49. package/dist/commands/flows/config/validate.js +3 -2
  50. package/dist/commands/flows/create.js +2 -2
  51. package/dist/commands/flows/get.js +3 -2
  52. package/dist/commands/flows/list.js +3 -2
  53. package/dist/commands/flows/media/upload.js +2 -2
  54. package/dist/commands/flows/publish.js +3 -2
  55. package/dist/commands/flows/update.js +3 -2
  56. package/dist/commands/paywalls/create.js +2 -2
  57. package/dist/commands/paywalls/get.js +3 -2
  58. package/dist/commands/paywalls/list.js +3 -2
  59. package/dist/commands/paywalls/placements.js +3 -2
  60. package/dist/commands/paywalls/update.js +3 -2
  61. package/dist/commands/placements/create.js +2 -2
  62. package/dist/commands/placements/get.js +3 -2
  63. package/dist/commands/placements/list.js +3 -2
  64. package/dist/commands/placements/update.js +3 -2
  65. package/dist/commands/products/create.js +2 -2
  66. package/dist/commands/products/get.js +3 -2
  67. package/dist/commands/products/list.js +3 -2
  68. package/dist/commands/products/update.js +3 -2
  69. package/dist/commands/segments/get.js +3 -2
  70. package/dist/commands/segments/list.js +3 -2
  71. package/dist/lib/flags.d.ts +0 -3
  72. package/dist/lib/flags.js +0 -13
  73. package/dist/sdk/attribution/catalog.d.ts +51 -0
  74. package/dist/sdk/attribution/catalog.js +6 -0
  75. package/dist/sdk/attribution/errors.d.ts +10 -0
  76. package/dist/sdk/attribution/errors.js +43 -0
  77. package/dist/sdk/attribution/index.d.ts +30 -0
  78. package/dist/sdk/attribution/index.js +33 -0
  79. package/dist/sdk/attribution/report.d.ts +75 -0
  80. package/dist/sdk/attribution/report.js +81 -0
  81. package/dist/sdk/attribution/resource.d.ts +30 -0
  82. package/dist/sdk/attribution/resource.js +51 -0
  83. package/dist/sdk/attribution/values.d.ts +39 -0
  84. package/dist/sdk/attribution/values.js +21 -0
  85. package/oclif.manifest.json +461 -192
  86. package/package.json +4 -1
@@ -1,13 +1,14 @@
1
1
  import { Command } from '@oclif/core';
2
+ import { appIdFlag } from '../../cli/flags.js';
2
3
  import { createAuthenticatedClient } from '../../lib/client-from-config.js';
3
- import { appFlag, paginationFlags, paginationParams } from '../../lib/flags.js';
4
+ import { paginationFlags, paginationParams } from '../../lib/flags.js';
4
5
  import { printList } from '../../lib/output.js';
5
6
  export default class ProductsList extends Command {
6
7
  static description = 'List products for an app';
7
8
  static enableJsonFlag = true;
8
9
  static examples = ['<%= config.bin %> products list --app 550e8400-...'];
9
10
  static flags = {
10
- ...appFlag,
11
+ ...appIdFlag,
11
12
  ...paginationFlags,
12
13
  };
13
14
  async run() {
@@ -1,6 +1,7 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
+ import { appIdFlag } from '../../cli/flags.js';
2
3
  import { createAuthenticatedClient } from '../../lib/client-from-config.js';
3
- import { appFlag, isValidUuid } from '../../lib/flags.js';
4
+ import { isValidUuid } from '../../lib/flags.js';
4
5
  import { printResponse } from '../../lib/output.js';
5
6
  export default class ProductsUpdate extends Command {
6
7
  static args = {
@@ -12,7 +13,7 @@ export default class ProductsUpdate extends Command {
12
13
  '<%= config.bin %> products update --app UUID 550e8400-... --title "Monthly" --access-level-id UUID',
13
14
  ];
14
15
  static flags = {
15
- ...appFlag,
16
+ ...appIdFlag,
16
17
  'access-level-id': Flags.string({ description: 'Access level ID (UUID)', required: true }),
17
18
  'title': Flags.string({ description: 'Product title', required: true }),
18
19
  };
@@ -1,6 +1,7 @@
1
1
  import { Args, Command } from '@oclif/core';
2
+ import { appIdFlag } from '../../cli/flags.js';
2
3
  import { createAuthenticatedClient } from '../../lib/client-from-config.js';
3
- import { appFlag, isValidUuid } from '../../lib/flags.js';
4
+ import { isValidUuid } from '../../lib/flags.js';
4
5
  import { printResponse } from '../../lib/output.js';
5
6
  export default class SegmentsGet extends Command {
6
7
  static args = {
@@ -10,7 +11,7 @@ export default class SegmentsGet extends Command {
10
11
  static enableJsonFlag = true;
11
12
  static examples = ['<%= config.bin %> segments get --app UUID 550e8400-e29b-41d4-a716-446655440000'];
12
13
  static flags = {
13
- ...appFlag,
14
+ ...appIdFlag,
14
15
  };
15
16
  async run() {
16
17
  const { args, flags } = await this.parse(SegmentsGet);
@@ -1,13 +1,14 @@
1
1
  import { Command } from '@oclif/core';
2
+ import { appIdFlag } from '../../cli/flags.js';
2
3
  import { createAuthenticatedClient } from '../../lib/client-from-config.js';
3
- import { appFlag, paginationFlags, paginationParams } from '../../lib/flags.js';
4
+ import { paginationFlags, paginationParams } from '../../lib/flags.js';
4
5
  import { printList } from '../../lib/output.js';
5
6
  export default class SegmentsList extends Command {
6
7
  static description = 'List segments for an app';
7
8
  static enableJsonFlag = true;
8
9
  static examples = ['<%= config.bin %> segments list --app 550e8400-...'];
9
10
  static flags = {
10
- ...appFlag,
11
+ ...appIdFlag,
11
12
  ...paginationFlags,
12
13
  };
13
14
  async run() {
@@ -1,7 +1,4 @@
1
1
  export declare function isValidUuid(value: string): boolean;
2
- export declare const appFlag: {
3
- app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
4
- };
5
2
  export type PaginatedResponse<T> = {
6
3
  data: T[];
7
4
  meta: {
package/dist/lib/flags.js CHANGED
@@ -3,19 +3,6 @@ const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
3
3
  export function isValidUuid(value) {
4
4
  return UUID_REGEX.test(value);
5
5
  }
6
- export const appFlag = {
7
- app: Flags.string({
8
- description: 'App ID (UUID)',
9
- // eslint-disable-next-line @typescript-eslint/require-await -- FIXME if you see this
10
- async parse(input) {
11
- if (!isValidUuid(input)) {
12
- throw new Error('Invalid app ID format. Run `adapty apps list` to find your app ID.');
13
- }
14
- return input;
15
- },
16
- required: true,
17
- }),
18
- };
19
6
  export const paginationFlags = {
20
7
  'page': Flags.integer({
21
8
  default: 1,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The vocabulary the backend describes itself with: which metrics a report can ask for and which
3
+ * dimensions it can group or filter by. Read-only and not scoped to an app, so the catalog is what
4
+ * an agent reads before it builds a report. Passed through as the server sends it, in snake_case.
5
+ */
6
+ export type MetricUnit = 'count' | 'per_mille' | 'percent' | 'usd';
7
+ export type Metric = {
8
+ /** Whether the totals row may sum this metric across groups. */
9
+ additive: boolean;
10
+ /** The metrics a ratio is computed from; null for a plain metric. */
11
+ denominator: null | string[];
12
+ description: string;
13
+ example: null | string;
14
+ family: string;
15
+ label: string;
16
+ name: string;
17
+ /** A name template for a family of metrics (e.g. a day horizon); null for a fixed name. */
18
+ pattern: null | string;
19
+ spend_based: boolean;
20
+ unit: MetricUnit;
21
+ };
22
+ /** The caps a report is held to, so a request can be sized before it is sent rather than refused. */
23
+ export type ReportLimits = {
24
+ max_filter_values: number;
25
+ max_keyword_length: number;
26
+ max_metrics: number;
27
+ max_prediction_day: number;
28
+ max_prediction_horizons: number;
29
+ max_prediction_non_date_dimensions: number;
30
+ max_rows: number;
31
+ /** The widest period in days, both ends counted: per date granularity, and `no_date_grouping`. */
32
+ max_window_days: Record<string, number>;
33
+ };
34
+ export type MetricCatalog = {
35
+ limits: ReportLimits;
36
+ metrics: Metric[];
37
+ };
38
+ /** id: items are entities with an id, a name and a channel. value: items are bare values. */
39
+ export type DimensionIdentity = 'id' | 'value';
40
+ export type Dimension = {
41
+ filterable: boolean;
42
+ /** The granularities a date grouping accepts; null for every other dimension. */
43
+ granularities: null | string[];
44
+ groupable: boolean;
45
+ identity: DimensionIdentity;
46
+ label: string;
47
+ name: string;
48
+ };
49
+ export type DimensionCatalog = {
50
+ dimensions: Dimension[];
51
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The vocabulary the backend describes itself with: which metrics a report can ask for and which
3
+ * dimensions it can group or filter by. Read-only and not scoped to an app, so the catalog is what
4
+ * an agent reads before it builds a report. Passed through as the server sends it, in snake_case.
5
+ */
6
+ export {};
@@ -0,0 +1,10 @@
1
+ import type { ErrorParser } from '../core/http/index.js';
2
+ /**
3
+ * How the attribution backend words a rejection: `{ errors: [{ message, error_code, status_code,
4
+ * field_name }] }`. The code comes from the first item; the message joins every item, each prefixed
5
+ * with the field it names, so "metrics: Unknown metric" reaches the user instead of an HTTP status.
6
+ *
7
+ * A body without the envelope (a proxy page, a framework-level 404) is read the default way.
8
+ * A 401 never gets here: the transport turns it into AuthRequiredError whatever the body says.
9
+ */
10
+ export declare const attributionErrorParser: ErrorParser;
@@ -0,0 +1,43 @@
1
+ import { defaultErrorParser } from '../core/http/index.js';
2
+ /**
3
+ * How the attribution backend words a rejection: `{ errors: [{ message, error_code, status_code,
4
+ * field_name }] }`. The code comes from the first item; the message joins every item, each prefixed
5
+ * with the field it names, so "metrics: Unknown metric" reaches the user instead of an HTTP status.
6
+ *
7
+ * A body without the envelope (a proxy page, a framework-level 404) is read the default way.
8
+ * A 401 never gets here: the transport turns it into AuthRequiredError whatever the body says.
9
+ */
10
+ export const attributionErrorParser = (status, body) => {
11
+ if (typeof body !== 'object' || body === null) {
12
+ return {};
13
+ }
14
+ const { errors } = body;
15
+ if (!Array.isArray(errors) || errors.length === 0) {
16
+ return defaultErrorParser(status, body);
17
+ }
18
+ const items = errors;
19
+ const code = codeOf(items[0]);
20
+ // The bare code as the fallback message: still better than an HTTP status
21
+ return { code, message: itemMessages(items) ?? code };
22
+ };
23
+ const codeOf = (item) => {
24
+ if (typeof item !== 'object' || item === null) {
25
+ return undefined;
26
+ }
27
+ const { error_code: errorCode } = item;
28
+ return typeof errorCode === 'string' && errorCode !== '' ? errorCode : undefined;
29
+ };
30
+ const itemMessages = (items) => {
31
+ const parts = [];
32
+ for (const item of items) {
33
+ if (typeof item !== 'object' || item === null) {
34
+ continue;
35
+ }
36
+ const { field_name: fieldName, message } = item;
37
+ if (typeof message !== 'string' || message === '') {
38
+ continue;
39
+ }
40
+ parts.push(typeof fieldName === 'string' && fieldName !== '' ? `${fieldName}: ${message}` : message);
41
+ }
42
+ return parts.length > 0 ? parts.join('; ') : undefined;
43
+ };
@@ -0,0 +1,30 @@
1
+ import type { AttributionApi } from './resource.js';
2
+ import type { Clock } from '../core/clock.js';
3
+ import type { RetryAttempt } from '../core/http/index.js';
4
+ export { attributionErrorParser } from './errors.js';
5
+ export { granularities, ISO_DATE, reportGroupBy, revenueBases, sortDirections, validateReport } from './report.js';
6
+ export { validateValues } from './values.js';
7
+ export type { Dimension, DimensionCatalog, DimensionIdentity, Metric, MetricCatalog, MetricUnit, ReportLimits, } from './catalog.js';
8
+ export type { Granularity, ReportData, ReportFilter, ReportGroupBy, ReportInput, ReportMeta, ReportRow, ReportSort, RevenueBasis, SortDirection, } from './report.js';
9
+ export type { AttributionApi, AttributionResponse, DimensionsResponse, MetricsResponse, ReportResponse, ValuesResponse, } from './resource.js';
10
+ export type { ValuesData, ValuesInput, ValuesItemById, ValuesItemByValue, ValuesMeta } from './values.js';
11
+ /** Exported because the adapter compares the resolved URL with it and warns when they differ. */
12
+ export declare const DEFAULT_ATTRIBUTION_API_URL = "https://api-ua.adapty.io/api/v1/cli";
13
+ /**
14
+ * Narrower than HttpOptions on purpose, like AdaptyOptions: the error parser, the retry rule and
15
+ * the path style are product knowledge, not something an adapter should override.
16
+ */
17
+ export type AttributionOptions = {
18
+ baseUrl?: string | undefined;
19
+ clock?: Clock | undefined;
20
+ fetch?: typeof globalThis.fetch | undefined;
21
+ onRetry?: ((info: RetryAttempt) => void) | undefined;
22
+ signal?: AbortSignal | undefined;
23
+ /** The Adapty developer token: the same bearer, no app header. */
24
+ token?: string | undefined;
25
+ /** Comes from the adapter, so server logs can tell the CLI from an MCP server. */
26
+ userAgent?: string | undefined;
27
+ };
28
+ export type Attribution = AttributionApi;
29
+ /** The assembly point of the attribution backend: one transport, the endpoints on top of it. */
30
+ export declare const createAttribution: (options?: AttributionOptions) => Attribution;
@@ -0,0 +1,33 @@
1
+ import { ApiError } from '../core/errors.js';
2
+ import { createHttp, defaultRetryPolicy, defaultShouldRetry } from '../core/http/index.js';
3
+ import { attributionErrorParser } from './errors.js';
4
+ import { attribution } from './resource.js';
5
+ export { attributionErrorParser } from './errors.js';
6
+ export { granularities, ISO_DATE, reportGroupBy, revenueBases, sortDirections, validateReport } from './report.js';
7
+ export { validateValues } from './values.js';
8
+ /** Exported because the adapter compares the resolved URL with it and warns when they differ. */
9
+ export const DEFAULT_ATTRIBUTION_API_URL = 'https://api-ua.adapty.io/api/v1/cli';
10
+ /**
11
+ * The server's Retry-After is honoured only up to the transport's longest backoff. A longer wait
12
+ * (60 s while Core is down) surfaces at once as an error carrying the delay, instead of a silent sleep.
13
+ */
14
+ const shouldRetry = (error, attempt) => (error instanceof ApiError && (error.retryAfterMs ?? 0) > defaultRetryPolicy.maxDelayMs
15
+ ? false
16
+ : defaultShouldRetry(error, attempt));
17
+ /** The assembly point of the attribution backend: one transport, the endpoints on top of it. */
18
+ export const createAttribution = (options = {}) => {
19
+ const http = createHttp({
20
+ baseUrl: options.baseUrl ?? DEFAULT_ATTRIBUTION_API_URL,
21
+ clock: options.clock,
22
+ fetch: options.fetch,
23
+ headers: options.userAgent === undefined ? undefined : { 'user-agent': options.userAgent },
24
+ onRetry: options.onRetry,
25
+ parseError: attributionErrorParser,
26
+ shouldRetry,
27
+ signal: options.signal,
28
+ token: options.token,
29
+ // The backend routes are FastAPI paths without a trailing slash, unlike the developer API
30
+ trailingSlash: false,
31
+ });
32
+ return attribution(http);
33
+ };
@@ -0,0 +1,75 @@
1
+ import type { Issue } from '../core/errors.js';
2
+ /** In the order the backend documents them, so a flag's help reads the same way. */
3
+ export declare const reportGroupBy: readonly ["date", "campaign", "adset", "ad", "keyword", "channel", "country", "store"];
4
+ export type ReportGroupBy = (typeof reportGroupBy)[number];
5
+ export declare const granularities: readonly ["day", "week", "month", "quarter", "year"];
6
+ export type Granularity = (typeof granularities)[number];
7
+ export declare const revenueBases: readonly ["gross", "proceeds", "net"];
8
+ export type RevenueBasis = (typeof revenueBases)[number];
9
+ export declare const sortDirections: readonly ["asc", "desc"];
10
+ export type SortDirection = (typeof sortDirections)[number];
11
+ /** One value filters by equality, several by any of them. */
12
+ export type ReportFilter = {
13
+ dimension: string;
14
+ values: readonly string[];
15
+ };
16
+ export type ReportSort = {
17
+ direction: SortDirection;
18
+ field: string;
19
+ };
20
+ /** Dates are inclusive days in the app's timezone, `YYYY-MM-DD`. */
21
+ export type ReportInput = {
22
+ appId: string;
23
+ dateFrom: string;
24
+ dateTo: string;
25
+ filters?: readonly ReportFilter[] | undefined;
26
+ granularity?: Granularity | undefined;
27
+ groupBy: readonly ReportGroupBy[];
28
+ /** Catalog names, as `attribution.metrics()` lists them. */
29
+ metrics: readonly string[];
30
+ revenueBasis?: RevenueBasis | undefined;
31
+ sort?: ReportSort | undefined;
32
+ };
33
+ type ReportRequest = {
34
+ app_id: string;
35
+ date_from: string;
36
+ date_to: string;
37
+ filters?: {
38
+ dimension: string;
39
+ values: string[];
40
+ }[];
41
+ granularity?: Granularity;
42
+ group_by: ReportGroupBy[];
43
+ metrics: string[];
44
+ revenue_basis?: RevenueBasis;
45
+ sort?: {
46
+ direction: SortDirection;
47
+ field: string;
48
+ };
49
+ };
50
+ /** Group keys and metric values by name; a metric that cannot be computed is null. */
51
+ export type ReportRow = Record<string, unknown>;
52
+ export type ReportData = {
53
+ rows: ReportRow[];
54
+ totals: null | Record<string, unknown>;
55
+ };
56
+ export type ReportMeta = {
57
+ /** The query as the backend resolved it: timezone, currency and defaults filled in. */
58
+ query: Record<string, unknown>;
59
+ };
60
+ /** The `YYYY-MM-DD` shape alone, shared with the adapter's date flag parser. */
61
+ export declare const ISO_DATE: RegExp;
62
+ /** Shared with values: both read a day range, and both are refused the same way. */
63
+ export declare const validateDateRange: (input: {
64
+ dateFrom: string;
65
+ dateTo: string;
66
+ }) => Issue[];
67
+ /**
68
+ * The rules a report can break without asking the server. Which metric and dimension names exist
69
+ * is the catalog's knowledge, so the backend checks those. An Issue path names an input field
70
+ * (camelCase) and the adapter turns it into the flag the user typed.
71
+ */
72
+ export declare const validateReport: (input: ReportInput) => Issue[];
73
+ /** An absent optional field is left out of the body: the backend applies its own default. */
74
+ export declare const toReportRequest: (input: ReportInput) => ReportRequest;
75
+ export {};
@@ -0,0 +1,81 @@
1
+ /** In the order the backend documents them, so a flag's help reads the same way. */
2
+ export const reportGroupBy = ['date', 'campaign', 'adset', 'ad', 'keyword', 'channel', 'country', 'store'];
3
+ export const granularities = ['day', 'week', 'month', 'quarter', 'year'];
4
+ export const revenueBases = ['gross', 'proceeds', 'net'];
5
+ export const sortDirections = ['asc', 'desc'];
6
+ /** The `YYYY-MM-DD` shape alone, shared with the adapter's date flag parser. */
7
+ export const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
8
+ /** The shape alone lets 2026-02-30 through; a round trip through Date does not. */
9
+ const isIsoDate = (value) => {
10
+ if (!ISO_DATE.test(value)) {
11
+ return false;
12
+ }
13
+ const date = new Date(`${value}T00:00:00Z`);
14
+ return !Number.isNaN(date.getTime()) && date.toISOString().startsWith(value);
15
+ };
16
+ /** Shared with values: both read a day range, and both are refused the same way. */
17
+ export const validateDateRange = (input) => {
18
+ const issues = [];
19
+ const fromValid = isIsoDate(input.dateFrom);
20
+ const toValid = isIsoDate(input.dateTo);
21
+ if (!fromValid) {
22
+ issues.push({ message: 'must be a date in YYYY-MM-DD form', path: 'dateFrom' });
23
+ }
24
+ if (!toValid) {
25
+ issues.push({ message: 'must be a date in YYYY-MM-DD form', path: 'dateTo' });
26
+ }
27
+ // ISO days order as strings, but only two real days are worth comparing
28
+ if (fromValid && toValid && input.dateTo < input.dateFrom) {
29
+ issues.push({ message: 'must not be before the start date', path: 'dateTo' });
30
+ }
31
+ return issues;
32
+ };
33
+ /**
34
+ * The rules a report can break without asking the server. Which metric and dimension names exist
35
+ * is the catalog's knowledge, so the backend checks those. An Issue path names an input field
36
+ * (camelCase) and the adapter turns it into the flag the user typed.
37
+ */
38
+ export const validateReport = (input) => {
39
+ const issues = validateDateRange(input);
40
+ if (input.metrics.length === 0) {
41
+ issues.push({ message: 'at least one metric is required', path: 'metrics' });
42
+ }
43
+ if (input.groupBy.length === 0) {
44
+ issues.push({ message: 'at least one grouping is required', path: 'groupBy' });
45
+ }
46
+ const groupsByDate = input.groupBy.includes('date');
47
+ if (input.granularity !== undefined && !groupsByDate) {
48
+ issues.push({ message: 'applies only when grouping by date', path: 'granularity' });
49
+ }
50
+ // The backend's rule, in its wording: without a bucket a date grouping is refused there anyway
51
+ if (input.granularity === undefined && groupsByDate) {
52
+ issues.push({
53
+ message: `grouping by date requires exactly one granularity: ${granularities.join(', ')}`,
54
+ path: 'granularity',
55
+ });
56
+ }
57
+ return issues;
58
+ };
59
+ /** An absent optional field is left out of the body: the backend applies its own default. */
60
+ export const toReportRequest = (input) => {
61
+ const body = {
62
+ app_id: input.appId,
63
+ date_from: input.dateFrom,
64
+ date_to: input.dateTo,
65
+ group_by: [...input.groupBy],
66
+ metrics: [...input.metrics],
67
+ };
68
+ if (input.filters !== undefined) {
69
+ body.filters = input.filters.map(filter => ({ dimension: filter.dimension, values: [...filter.values] }));
70
+ }
71
+ if (input.granularity !== undefined) {
72
+ body.granularity = input.granularity;
73
+ }
74
+ if (input.revenueBasis !== undefined) {
75
+ body.revenue_basis = input.revenueBasis;
76
+ }
77
+ if (input.sort !== undefined) {
78
+ body.sort = { direction: input.sort.direction, field: input.sort.field };
79
+ }
80
+ return body;
81
+ };
@@ -0,0 +1,30 @@
1
+ import type { DimensionCatalog, MetricCatalog } from './catalog.js';
2
+ import type { ReportData, ReportInput, ReportMeta } from './report.js';
3
+ import type { ValuesData, ValuesInput, ValuesMeta } from './values.js';
4
+ import type { Http } from '../core/http/index.js';
5
+ /** Every success of this service, passed through unchanged: that object is what --json prints. */
6
+ export type AttributionResponse<Data, Meta> = {
7
+ data: Data;
8
+ meta: Meta;
9
+ success: true;
10
+ };
11
+ export type MetricsResponse = AttributionResponse<MetricCatalog, null>;
12
+ export type DimensionsResponse = AttributionResponse<DimensionCatalog, null>;
13
+ export type ReportResponse = AttributionResponse<ReportData, ReportMeta>;
14
+ export type ValuesResponse = AttributionResponse<ValuesData, ValuesMeta>;
15
+ /**
16
+ * Every path of the attribution backend in one place. The catalog reads are GETs and keep the
17
+ * transport's retry. Report and values are POSTs that run analytics queries: sent once, never
18
+ * retried, so a busy or unavailable backend is not asked to run the same expensive query again —
19
+ * the ApiError carries the server's Retry-After for the caller to decide.
20
+ *
21
+ * The validating methods are async on purpose — a broken rule then arrives as a rejected promise,
22
+ * like a network failure, and one await covers both.
23
+ */
24
+ export declare const attribution: (http: Http) => {
25
+ dimensions: () => Promise<DimensionsResponse>;
26
+ metrics: () => Promise<MetricsResponse>;
27
+ report: (input: ReportInput) => Promise<ReportResponse>;
28
+ values: (input: ValuesInput) => Promise<ValuesResponse>;
29
+ };
30
+ export type AttributionApi = ReturnType<typeof attribution>;
@@ -0,0 +1,51 @@
1
+ import { ApiError } from '../core/errors.js';
2
+ import { assertValid } from '../core/validation.js';
3
+ import { toReportRequest, validateReport } from './report.js';
4
+ import { toValuesRequest, validateValues } from './values.js';
5
+ const isEnvelope = (body) => {
6
+ if (typeof body !== 'object' || body === null) {
7
+ return false;
8
+ }
9
+ const { data, success } = body;
10
+ return success === true && typeof data === 'object' && data !== null;
11
+ };
12
+ /**
13
+ * The transport hands back whatever a success body parsed to, and a proxy's HTML page parses to a
14
+ * string. Passed on, it would print under --json and exit 0, so a success without this service's
15
+ * envelope is an error: the counterpart of the legacy client's `malformed_response`.
16
+ */
17
+ const envelope = async (pending) => {
18
+ const body = await pending;
19
+ if (!isEnvelope(body)) {
20
+ throw new ApiError({
21
+ code: 'malformed_response',
22
+ details: body,
23
+ message: 'The attribution service answered with a success status but not with its response envelope, '
24
+ + 'so the answer was cut short or came from something in between. Nothing was read.',
25
+ // The transport keeps the status of an error only, and this service answers every success with 200
26
+ status: 200,
27
+ });
28
+ }
29
+ return body;
30
+ };
31
+ /**
32
+ * Every path of the attribution backend in one place. The catalog reads are GETs and keep the
33
+ * transport's retry. Report and values are POSTs that run analytics queries: sent once, never
34
+ * retried, so a busy or unavailable backend is not asked to run the same expensive query again —
35
+ * the ApiError carries the server's Retry-After for the caller to decide.
36
+ *
37
+ * The validating methods are async on purpose — a broken rule then arrives as a rejected promise,
38
+ * like a network failure, and one await covers both.
39
+ */
40
+ export const attribution = (http) => ({
41
+ dimensions: () => envelope(http.get('/dimensions')),
42
+ metrics: () => envelope(http.get('/metrics')),
43
+ report: async (input) => {
44
+ assertValid(validateReport(input));
45
+ return envelope(http.post('/report', toReportRequest(input), { idempotent: false }));
46
+ },
47
+ values: async (input) => {
48
+ assertValid(validateValues(input));
49
+ return envelope(http.post('/values', toValuesRequest(input), { idempotent: false }));
50
+ },
51
+ });
@@ -0,0 +1,39 @@
1
+ import type { RevenueBasis } from './report.js';
2
+ import type { Issue } from '../core/errors.js';
3
+ /** The values a dimension takes over a day range: what a report filter can be given. */
4
+ export type ValuesInput = {
5
+ appId: string;
6
+ dateFrom: string;
7
+ dateTo: string;
8
+ dimension: string;
9
+ revenueBasis?: RevenueBasis | undefined;
10
+ };
11
+ type ValuesRequest = {
12
+ app_id: string;
13
+ date_from: string;
14
+ date_to: string;
15
+ dimension: string;
16
+ revenue_basis?: RevenueBasis;
17
+ };
18
+ /** An item of a dimension whose catalog identity is `value`. */
19
+ export type ValuesItemByValue = {
20
+ /** null for rows the backend could not attribute to any value. */
21
+ value: null | string;
22
+ };
23
+ /** An item of a dimension whose catalog identity is `id`: an entity of one ad network. */
24
+ export type ValuesItemById = {
25
+ channel: null | string;
26
+ id: null | string;
27
+ name: null | string;
28
+ };
29
+ export type ValuesData = {
30
+ dimension: string;
31
+ items: (ValuesItemById | ValuesItemByValue)[];
32
+ };
33
+ export type ValuesMeta = {
34
+ query: Record<string, unknown>;
35
+ };
36
+ /** Whether the dimension exists is the catalog's knowledge, so the backend checks that. */
37
+ export declare const validateValues: (input: ValuesInput) => Issue[];
38
+ export declare const toValuesRequest: (input: ValuesInput) => ValuesRequest;
39
+ export {};
@@ -0,0 +1,21 @@
1
+ import { validateDateRange } from './report.js';
2
+ /** Whether the dimension exists is the catalog's knowledge, so the backend checks that. */
3
+ export const validateValues = (input) => {
4
+ const issues = validateDateRange(input);
5
+ if (input.dimension.trim() === '') {
6
+ issues.push({ message: 'must not be empty', path: 'dimension' });
7
+ }
8
+ return issues;
9
+ };
10
+ export const toValuesRequest = (input) => {
11
+ const body = {
12
+ app_id: input.appId,
13
+ date_from: input.dateFrom,
14
+ date_to: input.dateTo,
15
+ dimension: input.dimension,
16
+ };
17
+ if (input.revenueBasis !== undefined) {
18
+ body.revenue_basis = input.revenueBasis;
19
+ }
20
+ return body;
21
+ };