@rawdash/connector-branch 0.0.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.
- package/README.md +123 -0
- package/dist/index.d.ts +350 -0
- package/dist/index.js +437 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<!-- This file is generated from connector metadata by scripts/generate-connector-docs.ts. Do not edit by hand. -->
|
|
2
|
+
|
|
3
|
+
# @rawdash/connector-branch
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@rawdash/connector-branch)
|
|
6
|
+
[](https://github.com/rawdash/rawdash/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
Sync Branch install attribution metrics (installs, opens, conversions) and deep-link click events from the Cross-Platform Analytics API for mobile attribution dashboards.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @rawdash/connector-branch
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Authentication
|
|
17
|
+
|
|
18
|
+
A Branch app key and secret, used together to authenticate Cross-Platform Analytics API requests.
|
|
19
|
+
|
|
20
|
+
1. In the Branch dashboard, open Account Settings -> Profile and copy the Branch Key (starts with `key_live_`).
|
|
21
|
+
2. On the same screen, reveal and copy the Branch Secret (starts with `secret_live_`). Both values are app-scoped; keep them in a secret store.
|
|
22
|
+
3. Reference them from the connector config as `branchKey: secret("BRANCH_KEY")` and `branchSecret: secret("BRANCH_SECRET")`.
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
| Field | Type | Required | Description |
|
|
27
|
+
| -------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
28
|
+
| `branchKey` | secret | Yes | Your Branch app key (starts with `key_live_`). Find it in the Branch dashboard under Account Settings -> Profile. |
|
|
29
|
+
| `branchSecret` | secret | Yes | Your Branch app secret (starts with `secret_live_`). Find it next to the key in the Branch dashboard. |
|
|
30
|
+
| `lookbackDays` | number | No | How many calendar days of metrics/events to fetch on a full sync. Defaults to 90. |
|
|
31
|
+
| `resources` | array | No | Which Branch resources to sync. Omit to sync all of them. |
|
|
32
|
+
|
|
33
|
+
## Resources
|
|
34
|
+
|
|
35
|
+
- **`branch_install_metrics`** _(metric)_ - Daily Branch attribution metrics bucketed by channel and campaign. Primary value is `installs`; `opens`, `conversions`, and `costEstimated` are carried as attributes.
|
|
36
|
+
- Endpoint: `POST /v1/query/analytics`
|
|
37
|
+
- Unit: installs
|
|
38
|
+
- Granularity: day
|
|
39
|
+
- Dimensions: `date`, `channel`, `campaign`, `installs`, `opens`, `conversions`, `costEstimated`
|
|
40
|
+
- Merges three Aggregate API calls (data_source=eo_install, eo_open, eo_event) keyed by (date, channel, campaign). Rows with missing channel or campaign are recorded as `null` for that attribute.
|
|
41
|
+
- **`branch_deep_link_event`** _(event)_ - Daily aggregated Branch deep-link click events bucketed by channel, campaign, and feature. One event per (date, channel, campaign, feature) row carrying the daily click count.
|
|
42
|
+
- Endpoint: `POST /v1/query/analytics`
|
|
43
|
+
- Sourced from data_source=eo_click. Event id encodes the bucket so resyncs are idempotent.
|
|
44
|
+
- `date`: Calendar day of the click bucket (UTC).
|
|
45
|
+
- `channel`: Branch last-attributed channel.
|
|
46
|
+
- `campaign`: Branch last-attributed campaign.
|
|
47
|
+
- `feature`: Branch last-attributed feature (e.g. `sharing`).
|
|
48
|
+
- `clicks`: Click count for the bucket.
|
|
49
|
+
|
|
50
|
+
## Example
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import {
|
|
54
|
+
defineConfig,
|
|
55
|
+
defineDashboard,
|
|
56
|
+
defineMetric,
|
|
57
|
+
secret,
|
|
58
|
+
} from '@rawdash/core';
|
|
59
|
+
|
|
60
|
+
const branch = {
|
|
61
|
+
name: 'branch',
|
|
62
|
+
connectorId: 'branch',
|
|
63
|
+
config: {
|
|
64
|
+
branchKey: secret('BRANCH_KEY'),
|
|
65
|
+
branchSecret: secret('BRANCH_SECRET'),
|
|
66
|
+
lookbackDays: 90,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export default defineConfig({
|
|
71
|
+
connectors: [branch],
|
|
72
|
+
dashboards: {
|
|
73
|
+
mobile: defineDashboard({
|
|
74
|
+
widgets: {
|
|
75
|
+
installs_30d: {
|
|
76
|
+
kind: 'stat',
|
|
77
|
+
title: 'Branch installs (30d)',
|
|
78
|
+
window: '30d',
|
|
79
|
+
metric: defineMetric({
|
|
80
|
+
connector: branch,
|
|
81
|
+
shape: 'metric',
|
|
82
|
+
name: 'branch_install_metrics',
|
|
83
|
+
field: 'installs',
|
|
84
|
+
fn: 'sum',
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
daily_installs: {
|
|
88
|
+
kind: 'timeseries',
|
|
89
|
+
title: 'Daily installs by channel',
|
|
90
|
+
window: '30d',
|
|
91
|
+
metric: defineMetric({
|
|
92
|
+
connector: branch,
|
|
93
|
+
shape: 'metric',
|
|
94
|
+
name: 'branch_install_metrics',
|
|
95
|
+
field: 'installs',
|
|
96
|
+
fn: 'sum',
|
|
97
|
+
}),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Rate limits
|
|
106
|
+
|
|
107
|
+
Branch enforces a per-app request quota on the Cross-Platform Analytics API (roughly 1 request/second). The connector issues one POST per data source per resource per sync and respects 429 + Retry-After backoff via the shared HTTP client.
|
|
108
|
+
|
|
109
|
+
## Limitations
|
|
110
|
+
|
|
111
|
+
- Daily granularity only - the connector requests `granularity=day` from the Branch Aggregate API to keep result cardinality bounded.
|
|
112
|
+
- Cost attribution is best-effort - Branch only exposes `cost_in_local_currency` for ad-network-integrated channels. Rows without cost data carry `costEstimated: 0`.
|
|
113
|
+
- Deep-link events are aggregated daily click counts per (date, channel, campaign, feature). Individual click-level records require the Branch Daily Export API which is intentionally out of scope.
|
|
114
|
+
|
|
115
|
+
## Links
|
|
116
|
+
|
|
117
|
+
- [Rawdash docs](https://rawdash.dev/docs/connectors/)
|
|
118
|
+
- [Branch API docs](https://help.branch.io/developers-hub/reference)
|
|
119
|
+
- [GitHub](https://github.com/rawdash/rawdash)
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
Apache-2.0
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { BaseConnector, ConnectorContext, SyncOptions, StorageHandle, SyncResult, Event, ConnectorDoc, MetricSample } from '@rawdash/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
declare const configFields: z.ZodObject<{
|
|
5
|
+
branchKey: z.ZodObject<{
|
|
6
|
+
$secret: z.ZodString;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
branchSecret: z.ZodObject<{
|
|
9
|
+
$secret: z.ZodString;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
lookbackDays: z.ZodOptional<z.ZodNumber>;
|
|
12
|
+
resources: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
13
|
+
install_metrics: "install_metrics";
|
|
14
|
+
deep_link_events: "deep_link_events";
|
|
15
|
+
}>>>;
|
|
16
|
+
}, z.core.$strip>;
|
|
17
|
+
declare const doc: ConnectorDoc;
|
|
18
|
+
interface BranchSettings {
|
|
19
|
+
lookbackDays?: number;
|
|
20
|
+
resources?: readonly BranchResource[];
|
|
21
|
+
}
|
|
22
|
+
declare const branchCredentials: {
|
|
23
|
+
branchKey: {
|
|
24
|
+
description: string;
|
|
25
|
+
auth: "required";
|
|
26
|
+
};
|
|
27
|
+
branchSecret: {
|
|
28
|
+
description: string;
|
|
29
|
+
auth: "required";
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
type BranchCredentials = typeof branchCredentials;
|
|
33
|
+
declare const PHASE_ORDER: readonly ["install_metrics", "deep_link_events"];
|
|
34
|
+
type BranchPhase = (typeof PHASE_ORDER)[number];
|
|
35
|
+
type BranchResource = BranchPhase;
|
|
36
|
+
declare const INSTALL_DATA_SOURCES: readonly ["eo_install", "eo_open", "eo_event"];
|
|
37
|
+
type InstallDataSource = (typeof INSTALL_DATA_SOURCES)[number];
|
|
38
|
+
declare const installResultRowSchema: z.ZodObject<{
|
|
39
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
40
|
+
result: z.ZodObject<{
|
|
41
|
+
timestamp: z.ZodString;
|
|
42
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
43
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
44
|
+
cost_in_local_currency: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
45
|
+
}, z.core.$strip>;
|
|
46
|
+
}, z.core.$strip>;
|
|
47
|
+
declare const clickResultRowSchema: z.ZodObject<{
|
|
48
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
49
|
+
result: z.ZodObject<{
|
|
50
|
+
timestamp: z.ZodString;
|
|
51
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
52
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
53
|
+
last_attributed_touch_data_tilde_feature: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
54
|
+
}, z.core.$strip>;
|
|
55
|
+
}, z.core.$strip>;
|
|
56
|
+
declare const branchResources: {
|
|
57
|
+
readonly branch_install_metrics: {
|
|
58
|
+
readonly shape: "metric";
|
|
59
|
+
readonly description: "Daily Branch attribution metrics bucketed by channel and campaign. Primary value is `installs`; `opens`, `conversions`, and `costEstimated` are carried as attributes.";
|
|
60
|
+
readonly endpoint: "POST /v1/query/analytics";
|
|
61
|
+
readonly unit: "installs";
|
|
62
|
+
readonly granularity: "day";
|
|
63
|
+
readonly notes: "Merges three Aggregate API calls (data_source=eo_install, eo_open, eo_event) keyed by (date, channel, campaign). Rows with missing channel or campaign are recorded as `null` for that attribute.";
|
|
64
|
+
readonly dimensions: [{
|
|
65
|
+
readonly name: "date";
|
|
66
|
+
readonly description: "Calendar day of the metric sample (UTC).";
|
|
67
|
+
}, {
|
|
68
|
+
readonly name: "channel";
|
|
69
|
+
readonly description: "Branch last-attributed channel.";
|
|
70
|
+
}, {
|
|
71
|
+
readonly name: "campaign";
|
|
72
|
+
readonly description: "Branch last-attributed campaign.";
|
|
73
|
+
}, {
|
|
74
|
+
readonly name: "installs";
|
|
75
|
+
readonly description: "Attributed installs on the day.";
|
|
76
|
+
}, {
|
|
77
|
+
readonly name: "opens";
|
|
78
|
+
readonly description: "Attributed app opens on the day.";
|
|
79
|
+
}, {
|
|
80
|
+
readonly name: "conversions";
|
|
81
|
+
readonly description: "Attributed in-app conversion events on the day.";
|
|
82
|
+
}, {
|
|
83
|
+
readonly name: "costEstimated";
|
|
84
|
+
readonly description: "Estimated cost in the app local currency (only populated for ad-network-integrated channels; 0 otherwise).";
|
|
85
|
+
}];
|
|
86
|
+
readonly responses: {
|
|
87
|
+
readonly install_metrics_installs: z.ZodObject<{
|
|
88
|
+
results: z.ZodArray<z.ZodObject<{
|
|
89
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
90
|
+
result: z.ZodObject<{
|
|
91
|
+
timestamp: z.ZodString;
|
|
92
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
93
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
94
|
+
cost_in_local_currency: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
95
|
+
}, z.core.$strip>;
|
|
96
|
+
}, z.core.$strip>>;
|
|
97
|
+
}, z.core.$strip>;
|
|
98
|
+
readonly install_metrics_opens: z.ZodObject<{
|
|
99
|
+
results: z.ZodArray<z.ZodObject<{
|
|
100
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
101
|
+
result: z.ZodObject<{
|
|
102
|
+
timestamp: z.ZodString;
|
|
103
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
104
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
105
|
+
cost_in_local_currency: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
106
|
+
}, z.core.$strip>;
|
|
107
|
+
}, z.core.$strip>>;
|
|
108
|
+
}, z.core.$strip>;
|
|
109
|
+
readonly install_metrics_conversions: z.ZodObject<{
|
|
110
|
+
results: z.ZodArray<z.ZodObject<{
|
|
111
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
112
|
+
result: z.ZodObject<{
|
|
113
|
+
timestamp: z.ZodString;
|
|
114
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
115
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
116
|
+
cost_in_local_currency: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
117
|
+
}, z.core.$strip>;
|
|
118
|
+
}, z.core.$strip>>;
|
|
119
|
+
}, z.core.$strip>;
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
readonly branch_deep_link_event: {
|
|
123
|
+
readonly shape: "event";
|
|
124
|
+
readonly description: "Daily aggregated Branch deep-link click events bucketed by channel, campaign, and feature. One event per (date, channel, campaign, feature) row carrying the daily click count.";
|
|
125
|
+
readonly endpoint: "POST /v1/query/analytics";
|
|
126
|
+
readonly notes: "Sourced from data_source=eo_click. Event id encodes the bucket so resyncs are idempotent.";
|
|
127
|
+
readonly fields: [{
|
|
128
|
+
readonly name: "date";
|
|
129
|
+
readonly description: "Calendar day of the click bucket (UTC).";
|
|
130
|
+
}, {
|
|
131
|
+
readonly name: "channel";
|
|
132
|
+
readonly description: "Branch last-attributed channel.";
|
|
133
|
+
}, {
|
|
134
|
+
readonly name: "campaign";
|
|
135
|
+
readonly description: "Branch last-attributed campaign.";
|
|
136
|
+
}, {
|
|
137
|
+
readonly name: "feature";
|
|
138
|
+
readonly description: "Branch last-attributed feature (e.g. `sharing`).";
|
|
139
|
+
}, {
|
|
140
|
+
readonly name: "clicks";
|
|
141
|
+
readonly description: "Click count for the bucket.";
|
|
142
|
+
}];
|
|
143
|
+
readonly responses: {
|
|
144
|
+
readonly deep_link_events: z.ZodObject<{
|
|
145
|
+
results: z.ZodArray<z.ZodObject<{
|
|
146
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
147
|
+
result: z.ZodObject<{
|
|
148
|
+
timestamp: z.ZodString;
|
|
149
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
150
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
151
|
+
last_attributed_touch_data_tilde_feature: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
152
|
+
}, z.core.$strip>;
|
|
153
|
+
}, z.core.$strip>>;
|
|
154
|
+
}, z.core.$strip>;
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
type BranchInstallResultRow = z.infer<typeof installResultRowSchema>;
|
|
159
|
+
type BranchClickResultRow = z.infer<typeof clickResultRowSchema>;
|
|
160
|
+
interface BranchWindow {
|
|
161
|
+
from: string;
|
|
162
|
+
to: string;
|
|
163
|
+
}
|
|
164
|
+
declare function getWindow(options: SyncOptions, lookbackDays: number, now?: number): BranchWindow;
|
|
165
|
+
interface InstallBucket {
|
|
166
|
+
date: string;
|
|
167
|
+
channel: string | null;
|
|
168
|
+
campaign: string | null;
|
|
169
|
+
installs: number;
|
|
170
|
+
opens: number;
|
|
171
|
+
conversions: number;
|
|
172
|
+
costEstimated: number;
|
|
173
|
+
}
|
|
174
|
+
declare function mergeInstallBuckets(rowsByDataSource: Record<InstallDataSource, BranchInstallResultRow[]>): InstallBucket[];
|
|
175
|
+
declare function installBucketToMetricSample(bucket: InstallBucket): MetricSample;
|
|
176
|
+
declare function clickRowToEventRecord(row: BranchClickResultRow): Event;
|
|
177
|
+
declare const id = "branch";
|
|
178
|
+
declare class BranchConnector extends BaseConnector<BranchSettings, BranchCredentials> {
|
|
179
|
+
static readonly id = "branch";
|
|
180
|
+
static readonly resources: {
|
|
181
|
+
readonly branch_install_metrics: {
|
|
182
|
+
readonly shape: "metric";
|
|
183
|
+
readonly description: "Daily Branch attribution metrics bucketed by channel and campaign. Primary value is `installs`; `opens`, `conversions`, and `costEstimated` are carried as attributes.";
|
|
184
|
+
readonly endpoint: "POST /v1/query/analytics";
|
|
185
|
+
readonly unit: "installs";
|
|
186
|
+
readonly granularity: "day";
|
|
187
|
+
readonly notes: "Merges three Aggregate API calls (data_source=eo_install, eo_open, eo_event) keyed by (date, channel, campaign). Rows with missing channel or campaign are recorded as `null` for that attribute.";
|
|
188
|
+
readonly dimensions: [{
|
|
189
|
+
readonly name: "date";
|
|
190
|
+
readonly description: "Calendar day of the metric sample (UTC).";
|
|
191
|
+
}, {
|
|
192
|
+
readonly name: "channel";
|
|
193
|
+
readonly description: "Branch last-attributed channel.";
|
|
194
|
+
}, {
|
|
195
|
+
readonly name: "campaign";
|
|
196
|
+
readonly description: "Branch last-attributed campaign.";
|
|
197
|
+
}, {
|
|
198
|
+
readonly name: "installs";
|
|
199
|
+
readonly description: "Attributed installs on the day.";
|
|
200
|
+
}, {
|
|
201
|
+
readonly name: "opens";
|
|
202
|
+
readonly description: "Attributed app opens on the day.";
|
|
203
|
+
}, {
|
|
204
|
+
readonly name: "conversions";
|
|
205
|
+
readonly description: "Attributed in-app conversion events on the day.";
|
|
206
|
+
}, {
|
|
207
|
+
readonly name: "costEstimated";
|
|
208
|
+
readonly description: "Estimated cost in the app local currency (only populated for ad-network-integrated channels; 0 otherwise).";
|
|
209
|
+
}];
|
|
210
|
+
readonly responses: {
|
|
211
|
+
readonly install_metrics_installs: z.ZodObject<{
|
|
212
|
+
results: z.ZodArray<z.ZodObject<{
|
|
213
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
214
|
+
result: z.ZodObject<{
|
|
215
|
+
timestamp: z.ZodString;
|
|
216
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
217
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
218
|
+
cost_in_local_currency: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
219
|
+
}, z.core.$strip>;
|
|
220
|
+
}, z.core.$strip>>;
|
|
221
|
+
}, z.core.$strip>;
|
|
222
|
+
readonly install_metrics_opens: z.ZodObject<{
|
|
223
|
+
results: z.ZodArray<z.ZodObject<{
|
|
224
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
225
|
+
result: z.ZodObject<{
|
|
226
|
+
timestamp: z.ZodString;
|
|
227
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
228
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
229
|
+
cost_in_local_currency: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
230
|
+
}, z.core.$strip>;
|
|
231
|
+
}, z.core.$strip>>;
|
|
232
|
+
}, z.core.$strip>;
|
|
233
|
+
readonly install_metrics_conversions: z.ZodObject<{
|
|
234
|
+
results: z.ZodArray<z.ZodObject<{
|
|
235
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
236
|
+
result: z.ZodObject<{
|
|
237
|
+
timestamp: z.ZodString;
|
|
238
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
239
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
240
|
+
cost_in_local_currency: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
241
|
+
}, z.core.$strip>;
|
|
242
|
+
}, z.core.$strip>>;
|
|
243
|
+
}, z.core.$strip>;
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
readonly branch_deep_link_event: {
|
|
247
|
+
readonly shape: "event";
|
|
248
|
+
readonly description: "Daily aggregated Branch deep-link click events bucketed by channel, campaign, and feature. One event per (date, channel, campaign, feature) row carrying the daily click count.";
|
|
249
|
+
readonly endpoint: "POST /v1/query/analytics";
|
|
250
|
+
readonly notes: "Sourced from data_source=eo_click. Event id encodes the bucket so resyncs are idempotent.";
|
|
251
|
+
readonly fields: [{
|
|
252
|
+
readonly name: "date";
|
|
253
|
+
readonly description: "Calendar day of the click bucket (UTC).";
|
|
254
|
+
}, {
|
|
255
|
+
readonly name: "channel";
|
|
256
|
+
readonly description: "Branch last-attributed channel.";
|
|
257
|
+
}, {
|
|
258
|
+
readonly name: "campaign";
|
|
259
|
+
readonly description: "Branch last-attributed campaign.";
|
|
260
|
+
}, {
|
|
261
|
+
readonly name: "feature";
|
|
262
|
+
readonly description: "Branch last-attributed feature (e.g. `sharing`).";
|
|
263
|
+
}, {
|
|
264
|
+
readonly name: "clicks";
|
|
265
|
+
readonly description: "Click count for the bucket.";
|
|
266
|
+
}];
|
|
267
|
+
readonly responses: {
|
|
268
|
+
readonly deep_link_events: z.ZodObject<{
|
|
269
|
+
results: z.ZodArray<z.ZodObject<{
|
|
270
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
271
|
+
result: z.ZodObject<{
|
|
272
|
+
timestamp: z.ZodString;
|
|
273
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
274
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
275
|
+
last_attributed_touch_data_tilde_feature: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
276
|
+
}, z.core.$strip>;
|
|
277
|
+
}, z.core.$strip>>;
|
|
278
|
+
}, z.core.$strip>;
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
static readonly schemas: {
|
|
283
|
+
readonly install_metrics_installs: z.ZodObject<{
|
|
284
|
+
results: z.ZodArray<z.ZodObject<{
|
|
285
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
286
|
+
result: z.ZodObject<{
|
|
287
|
+
timestamp: z.ZodString;
|
|
288
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
289
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
290
|
+
cost_in_local_currency: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
291
|
+
}, z.core.$strip>;
|
|
292
|
+
}, z.core.$strip>>;
|
|
293
|
+
}, z.core.$strip>;
|
|
294
|
+
readonly install_metrics_opens: z.ZodObject<{
|
|
295
|
+
results: z.ZodArray<z.ZodObject<{
|
|
296
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
297
|
+
result: z.ZodObject<{
|
|
298
|
+
timestamp: z.ZodString;
|
|
299
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
300
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
301
|
+
cost_in_local_currency: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
302
|
+
}, z.core.$strip>;
|
|
303
|
+
}, z.core.$strip>>;
|
|
304
|
+
}, z.core.$strip>;
|
|
305
|
+
readonly install_metrics_conversions: z.ZodObject<{
|
|
306
|
+
results: z.ZodArray<z.ZodObject<{
|
|
307
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
308
|
+
result: z.ZodObject<{
|
|
309
|
+
timestamp: z.ZodString;
|
|
310
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
311
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
312
|
+
cost_in_local_currency: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
313
|
+
}, z.core.$strip>;
|
|
314
|
+
}, z.core.$strip>>;
|
|
315
|
+
}, z.core.$strip>;
|
|
316
|
+
} & {
|
|
317
|
+
readonly deep_link_events: z.ZodObject<{
|
|
318
|
+
results: z.ZodArray<z.ZodObject<{
|
|
319
|
+
unique_count: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodNull]>>;
|
|
320
|
+
result: z.ZodObject<{
|
|
321
|
+
timestamp: z.ZodString;
|
|
322
|
+
last_attributed_touch_data_tilde_channel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
323
|
+
last_attributed_touch_data_tilde_campaign: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
324
|
+
last_attributed_touch_data_tilde_feature: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
325
|
+
}, z.core.$strip>;
|
|
326
|
+
}, z.core.$strip>>;
|
|
327
|
+
}, z.core.$strip>;
|
|
328
|
+
} & Readonly<Record<string, z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>>;
|
|
329
|
+
static create(input: unknown, ctx?: ConnectorContext): BranchConnector;
|
|
330
|
+
readonly id = "branch";
|
|
331
|
+
readonly credentials: {
|
|
332
|
+
branchKey: {
|
|
333
|
+
description: string;
|
|
334
|
+
auth: "required";
|
|
335
|
+
};
|
|
336
|
+
branchSecret: {
|
|
337
|
+
description: string;
|
|
338
|
+
auth: "required";
|
|
339
|
+
};
|
|
340
|
+
};
|
|
341
|
+
private buildHeaders;
|
|
342
|
+
private buildBody;
|
|
343
|
+
private fetchAggregate;
|
|
344
|
+
private fetchInstallBuckets;
|
|
345
|
+
private fetchClickRows;
|
|
346
|
+
private writePhase;
|
|
347
|
+
sync(options: SyncOptions, storage: StorageHandle, signal?: AbortSignal): Promise<SyncResult>;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export { type BranchClickResultRow, BranchConnector, type BranchInstallResultRow, type BranchResource, type BranchSettings, clickRowToEventRecord, configFields, BranchConnector as default, doc, getWindow, id, installBucketToMetricSample, mergeInstallBuckets, branchResources as resources };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
// ../../connector-shared/dist/index.js
|
|
2
|
+
var HTTP_CLIENT_VERSION = "0.0.0";
|
|
3
|
+
var DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
|
|
4
|
+
function connectorUserAgent(connectorId) {
|
|
5
|
+
return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// src/branch.ts
|
|
9
|
+
import {
|
|
10
|
+
BaseConnector,
|
|
11
|
+
defineConfigFields,
|
|
12
|
+
defineConnectorDoc,
|
|
13
|
+
defineResources,
|
|
14
|
+
makeChunkedCursorGuard,
|
|
15
|
+
paginateChunked,
|
|
16
|
+
schemasFromResources,
|
|
17
|
+
selectActivePhases
|
|
18
|
+
} from "@rawdash/core";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
var configFields = defineConfigFields(
|
|
21
|
+
z.object({
|
|
22
|
+
branchKey: z.object({ $secret: z.string() }).meta({
|
|
23
|
+
label: "Branch key",
|
|
24
|
+
description: "Your Branch app key (starts with `key_live_`). Find it in the Branch dashboard under Account Settings -> Profile.",
|
|
25
|
+
placeholder: "key_live_xxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
26
|
+
secret: true
|
|
27
|
+
}),
|
|
28
|
+
branchSecret: z.object({ $secret: z.string() }).meta({
|
|
29
|
+
label: "Branch secret",
|
|
30
|
+
description: "Your Branch app secret (starts with `secret_live_`). Find it next to the key in the Branch dashboard.",
|
|
31
|
+
placeholder: "secret_live_xxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
32
|
+
secret: true
|
|
33
|
+
}),
|
|
34
|
+
lookbackDays: z.number().int().positive().optional().meta({
|
|
35
|
+
label: "Lookback days (full sync)",
|
|
36
|
+
description: "How many calendar days of metrics/events to fetch on a full sync. Defaults to 90.",
|
|
37
|
+
placeholder: "90"
|
|
38
|
+
}),
|
|
39
|
+
resources: z.array(z.enum(["install_metrics", "deep_link_events"])).nonempty().optional().meta({
|
|
40
|
+
label: "Resources",
|
|
41
|
+
description: "Which Branch resources to sync. Omit to sync all of them."
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
var doc = defineConnectorDoc({
|
|
46
|
+
displayName: "Branch",
|
|
47
|
+
category: "marketing",
|
|
48
|
+
brandColor: "#7CB833",
|
|
49
|
+
tagline: "Sync Branch install attribution metrics (installs, opens, conversions) and deep-link click events from the Cross-Platform Analytics API for mobile attribution dashboards.",
|
|
50
|
+
vendor: {
|
|
51
|
+
name: "Branch",
|
|
52
|
+
domain: "branch.io",
|
|
53
|
+
apiDocs: "https://help.branch.io/developers-hub/reference",
|
|
54
|
+
website: "https://www.branch.io"
|
|
55
|
+
},
|
|
56
|
+
auth: {
|
|
57
|
+
summary: "A Branch app key and secret, used together to authenticate Cross-Platform Analytics API requests.",
|
|
58
|
+
setup: [
|
|
59
|
+
"In the Branch dashboard, open Account Settings -> Profile and copy the Branch Key (starts with `key_live_`).",
|
|
60
|
+
"On the same screen, reveal and copy the Branch Secret (starts with `secret_live_`). Both values are app-scoped; keep them in a secret store.",
|
|
61
|
+
'Reference them from the connector config as `branchKey: secret("BRANCH_KEY")` and `branchSecret: secret("BRANCH_SECRET")`.'
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
rateLimit: "Branch enforces a per-app request quota on the Cross-Platform Analytics API (roughly 1 request/second). The connector issues one POST per data source per resource per sync and respects 429 + Retry-After backoff via the shared HTTP client.",
|
|
65
|
+
limitations: [
|
|
66
|
+
"Daily granularity only - the connector requests `granularity=day` from the Branch Aggregate API to keep result cardinality bounded.",
|
|
67
|
+
"Cost attribution is best-effort - Branch only exposes `cost_in_local_currency` for ad-network-integrated channels. Rows without cost data carry `costEstimated: 0`.",
|
|
68
|
+
"Deep-link events are aggregated daily click counts per (date, channel, campaign, feature). Individual click-level records require the Branch Daily Export API which is intentionally out of scope."
|
|
69
|
+
]
|
|
70
|
+
});
|
|
71
|
+
var branchCredentials = {
|
|
72
|
+
branchKey: {
|
|
73
|
+
description: "Branch app key (key_live_...)",
|
|
74
|
+
auth: "required"
|
|
75
|
+
},
|
|
76
|
+
branchSecret: {
|
|
77
|
+
description: "Branch app secret (secret_live_...)",
|
|
78
|
+
auth: "required"
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
var PHASE_ORDER = ["install_metrics", "deep_link_events"];
|
|
82
|
+
var isBranchSyncCursor = makeChunkedCursorGuard(PHASE_ORDER);
|
|
83
|
+
var ANALYTICS_API_URL = "https://api2.branch.io/v1/query/analytics";
|
|
84
|
+
var MS_PER_DAY = 24 * 60 * 60 * 1e3;
|
|
85
|
+
var DEFAULT_LOOKBACK_DAYS = 90;
|
|
86
|
+
var INCREMENTAL_LOOKBACK_DAYS = 14;
|
|
87
|
+
var INSTALL_METRIC_NAME = "branch_install_metrics";
|
|
88
|
+
var DEEP_LINK_EVENT_NAME = "branch_deep_link_event";
|
|
89
|
+
var CHANNEL_DIMENSION = "last_attributed_touch_data_tilde_channel";
|
|
90
|
+
var CAMPAIGN_DIMENSION = "last_attributed_touch_data_tilde_campaign";
|
|
91
|
+
var FEATURE_DIMENSION = "last_attributed_touch_data_tilde_feature";
|
|
92
|
+
var INSTALL_DATA_SOURCES = ["eo_install", "eo_open", "eo_event"];
|
|
93
|
+
var COUNT_FIELD_BY_DATA_SOURCE = {
|
|
94
|
+
eo_install: "installs",
|
|
95
|
+
eo_open: "opens",
|
|
96
|
+
eo_event: "conversions"
|
|
97
|
+
};
|
|
98
|
+
var isoDateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
|
99
|
+
var numericLike = z.union([z.number(), z.string(), z.null()]).optional();
|
|
100
|
+
var installResultRowSchema = z.object({
|
|
101
|
+
unique_count: numericLike,
|
|
102
|
+
result: z.object({
|
|
103
|
+
timestamp: isoDateString,
|
|
104
|
+
[CHANNEL_DIMENSION]: z.string().nullish(),
|
|
105
|
+
[CAMPAIGN_DIMENSION]: z.string().nullish(),
|
|
106
|
+
cost_in_local_currency: numericLike
|
|
107
|
+
})
|
|
108
|
+
});
|
|
109
|
+
var installResponseSchema = z.object({
|
|
110
|
+
results: z.array(installResultRowSchema)
|
|
111
|
+
});
|
|
112
|
+
var clickResultRowSchema = z.object({
|
|
113
|
+
unique_count: numericLike,
|
|
114
|
+
result: z.object({
|
|
115
|
+
timestamp: isoDateString,
|
|
116
|
+
[CHANNEL_DIMENSION]: z.string().nullish(),
|
|
117
|
+
[CAMPAIGN_DIMENSION]: z.string().nullish(),
|
|
118
|
+
[FEATURE_DIMENSION]: z.string().nullish()
|
|
119
|
+
})
|
|
120
|
+
});
|
|
121
|
+
var clickResponseSchema = z.object({
|
|
122
|
+
results: z.array(clickResultRowSchema)
|
|
123
|
+
});
|
|
124
|
+
var branchResources = defineResources({
|
|
125
|
+
[INSTALL_METRIC_NAME]: {
|
|
126
|
+
shape: "metric",
|
|
127
|
+
description: "Daily Branch attribution metrics bucketed by channel and campaign. Primary value is `installs`; `opens`, `conversions`, and `costEstimated` are carried as attributes.",
|
|
128
|
+
endpoint: "POST /v1/query/analytics",
|
|
129
|
+
unit: "installs",
|
|
130
|
+
granularity: "day",
|
|
131
|
+
notes: "Merges three Aggregate API calls (data_source=eo_install, eo_open, eo_event) keyed by (date, channel, campaign). Rows with missing channel or campaign are recorded as `null` for that attribute.",
|
|
132
|
+
dimensions: [
|
|
133
|
+
{ name: "date", description: "Calendar day of the metric sample (UTC)." },
|
|
134
|
+
{ name: "channel", description: "Branch last-attributed channel." },
|
|
135
|
+
{ name: "campaign", description: "Branch last-attributed campaign." },
|
|
136
|
+
{ name: "installs", description: "Attributed installs on the day." },
|
|
137
|
+
{ name: "opens", description: "Attributed app opens on the day." },
|
|
138
|
+
{
|
|
139
|
+
name: "conversions",
|
|
140
|
+
description: "Attributed in-app conversion events on the day."
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "costEstimated",
|
|
144
|
+
description: "Estimated cost in the app local currency (only populated for ad-network-integrated channels; 0 otherwise)."
|
|
145
|
+
}
|
|
146
|
+
],
|
|
147
|
+
responses: {
|
|
148
|
+
install_metrics_installs: installResponseSchema,
|
|
149
|
+
install_metrics_opens: installResponseSchema,
|
|
150
|
+
install_metrics_conversions: installResponseSchema
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
[DEEP_LINK_EVENT_NAME]: {
|
|
154
|
+
shape: "event",
|
|
155
|
+
description: "Daily aggregated Branch deep-link click events bucketed by channel, campaign, and feature. One event per (date, channel, campaign, feature) row carrying the daily click count.",
|
|
156
|
+
endpoint: "POST /v1/query/analytics",
|
|
157
|
+
notes: "Sourced from data_source=eo_click. Event id encodes the bucket so resyncs are idempotent.",
|
|
158
|
+
fields: [
|
|
159
|
+
{ name: "date", description: "Calendar day of the click bucket (UTC)." },
|
|
160
|
+
{ name: "channel", description: "Branch last-attributed channel." },
|
|
161
|
+
{ name: "campaign", description: "Branch last-attributed campaign." },
|
|
162
|
+
{
|
|
163
|
+
name: "feature",
|
|
164
|
+
description: "Branch last-attributed feature (e.g. `sharing`)."
|
|
165
|
+
},
|
|
166
|
+
{ name: "clicks", description: "Click count for the bucket." }
|
|
167
|
+
],
|
|
168
|
+
responses: { deep_link_events: clickResponseSchema }
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
function pad2(n) {
|
|
172
|
+
return String(n).padStart(2, "0");
|
|
173
|
+
}
|
|
174
|
+
function toIsoDate(ms) {
|
|
175
|
+
const d = new Date(ms);
|
|
176
|
+
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
|
177
|
+
}
|
|
178
|
+
function startOfUtcDay(ms) {
|
|
179
|
+
return Math.floor(ms / MS_PER_DAY) * MS_PER_DAY;
|
|
180
|
+
}
|
|
181
|
+
function getWindow(options, lookbackDays, now = Date.now()) {
|
|
182
|
+
const today = startOfUtcDay(now);
|
|
183
|
+
if (options.mode === "latest") {
|
|
184
|
+
return {
|
|
185
|
+
from: toIsoDate(today - (INCREMENTAL_LOOKBACK_DAYS - 1) * MS_PER_DAY),
|
|
186
|
+
to: toIsoDate(today)
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (options.since) {
|
|
190
|
+
const sinceMs = new Date(options.since).getTime();
|
|
191
|
+
if (Number.isFinite(sinceMs)) {
|
|
192
|
+
const requested = Math.max(
|
|
193
|
+
1,
|
|
194
|
+
Math.ceil((today - startOfUtcDay(sinceMs)) / MS_PER_DAY) + 1
|
|
195
|
+
);
|
|
196
|
+
const capped = Math.min(requested, lookbackDays);
|
|
197
|
+
return {
|
|
198
|
+
from: toIsoDate(today - (capped - 1) * MS_PER_DAY),
|
|
199
|
+
to: toIsoDate(today)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
from: toIsoDate(today - (lookbackDays - 1) * MS_PER_DAY),
|
|
205
|
+
to: toIsoDate(today)
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function isoDateToMs(date) {
|
|
209
|
+
const [y, m, d] = date.split("-").map((part) => Number(part));
|
|
210
|
+
if (y === void 0 || m === void 0 || d === void 0 || !Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) {
|
|
211
|
+
return NaN;
|
|
212
|
+
}
|
|
213
|
+
return Date.UTC(y, m - 1, d);
|
|
214
|
+
}
|
|
215
|
+
function parseNumber(value) {
|
|
216
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
220
|
+
const n = Number(value);
|
|
221
|
+
return Number.isFinite(n) ? n : 0;
|
|
222
|
+
}
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
function normalizeDateBucket(timestamp) {
|
|
226
|
+
return timestamp.slice(0, 10);
|
|
227
|
+
}
|
|
228
|
+
function bucketKey(date, channel, campaign) {
|
|
229
|
+
return `${date}${channel ?? ""}${campaign ?? ""}`;
|
|
230
|
+
}
|
|
231
|
+
function mergeInstallBuckets(rowsByDataSource) {
|
|
232
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
233
|
+
for (const dataSource of INSTALL_DATA_SOURCES) {
|
|
234
|
+
const field = COUNT_FIELD_BY_DATA_SOURCE[dataSource];
|
|
235
|
+
for (const row of rowsByDataSource[dataSource]) {
|
|
236
|
+
const date = normalizeDateBucket(row.result.timestamp);
|
|
237
|
+
const channel = row.result[CHANNEL_DIMENSION] ?? null;
|
|
238
|
+
const campaign = row.result[CAMPAIGN_DIMENSION] ?? null;
|
|
239
|
+
const key = bucketKey(date, channel, campaign);
|
|
240
|
+
let bucket = buckets.get(key);
|
|
241
|
+
if (!bucket) {
|
|
242
|
+
bucket = {
|
|
243
|
+
date,
|
|
244
|
+
channel,
|
|
245
|
+
campaign,
|
|
246
|
+
installs: 0,
|
|
247
|
+
opens: 0,
|
|
248
|
+
conversions: 0,
|
|
249
|
+
costEstimated: 0
|
|
250
|
+
};
|
|
251
|
+
buckets.set(key, bucket);
|
|
252
|
+
}
|
|
253
|
+
const count = parseNumber(row.unique_count);
|
|
254
|
+
if (field === "installs") {
|
|
255
|
+
bucket.installs += count;
|
|
256
|
+
} else if (field === "opens") {
|
|
257
|
+
bucket.opens += count;
|
|
258
|
+
} else {
|
|
259
|
+
bucket.conversions += count;
|
|
260
|
+
}
|
|
261
|
+
if (dataSource === "eo_install") {
|
|
262
|
+
bucket.costEstimated += parseNumber(row.result.cost_in_local_currency);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return Array.from(buckets.values()).sort(
|
|
267
|
+
(a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
function installBucketToMetricSample(bucket) {
|
|
271
|
+
const ts = isoDateToMs(bucket.date);
|
|
272
|
+
return {
|
|
273
|
+
name: INSTALL_METRIC_NAME,
|
|
274
|
+
ts: Number.isFinite(ts) ? ts : 0,
|
|
275
|
+
value: bucket.installs,
|
|
276
|
+
attributes: {
|
|
277
|
+
date: bucket.date,
|
|
278
|
+
channel: bucket.channel,
|
|
279
|
+
campaign: bucket.campaign,
|
|
280
|
+
installs: bucket.installs,
|
|
281
|
+
opens: bucket.opens,
|
|
282
|
+
conversions: bucket.conversions,
|
|
283
|
+
costEstimated: bucket.costEstimated
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function clickRowToEventRecord(row) {
|
|
288
|
+
const date = normalizeDateBucket(row.result.timestamp);
|
|
289
|
+
const channel = row.result[CHANNEL_DIMENSION] ?? null;
|
|
290
|
+
const campaign = row.result[CAMPAIGN_DIMENSION] ?? null;
|
|
291
|
+
const feature = row.result[FEATURE_DIMENSION] ?? null;
|
|
292
|
+
const ts = isoDateToMs(date);
|
|
293
|
+
const clicks = parseNumber(row.unique_count);
|
|
294
|
+
const startTs = Number.isFinite(ts) ? ts : 0;
|
|
295
|
+
return {
|
|
296
|
+
name: DEEP_LINK_EVENT_NAME,
|
|
297
|
+
start_ts: startTs,
|
|
298
|
+
end_ts: startTs,
|
|
299
|
+
attributes: {
|
|
300
|
+
bucketKey: `${date}|${channel ?? ""}|${campaign ?? ""}|${feature ?? ""}`,
|
|
301
|
+
date,
|
|
302
|
+
channel,
|
|
303
|
+
campaign,
|
|
304
|
+
feature,
|
|
305
|
+
clicks
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
var id = "branch";
|
|
310
|
+
var BranchConnector = class _BranchConnector extends BaseConnector {
|
|
311
|
+
static id = id;
|
|
312
|
+
static resources = branchResources;
|
|
313
|
+
static schemas = schemasFromResources(branchResources);
|
|
314
|
+
static create(input, ctx) {
|
|
315
|
+
const parsed = configFields.parse(input);
|
|
316
|
+
return new _BranchConnector(
|
|
317
|
+
{ lookbackDays: parsed.lookbackDays, resources: parsed.resources },
|
|
318
|
+
{ branchKey: parsed.branchKey, branchSecret: parsed.branchSecret },
|
|
319
|
+
ctx
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
id = id;
|
|
323
|
+
credentials = branchCredentials;
|
|
324
|
+
buildHeaders() {
|
|
325
|
+
return {
|
|
326
|
+
"Content-Type": "application/json",
|
|
327
|
+
Accept: "application/json",
|
|
328
|
+
"User-Agent": connectorUserAgent("branch")
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
buildBody(dataSource, dimensions, window) {
|
|
332
|
+
return JSON.stringify({
|
|
333
|
+
branch_key: this.creds.branchKey,
|
|
334
|
+
branch_secret: this.creds.branchSecret,
|
|
335
|
+
start_date: window.from,
|
|
336
|
+
end_date: window.to,
|
|
337
|
+
data_source: dataSource,
|
|
338
|
+
dimensions,
|
|
339
|
+
granularity: "day",
|
|
340
|
+
aggregation: "unique_count",
|
|
341
|
+
ordered: "ascending",
|
|
342
|
+
ordered_by: "timestamp"
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
async fetchAggregate(resource, dataSource, dimensions, window, signal) {
|
|
346
|
+
const res = await this.post(ANALYTICS_API_URL, {
|
|
347
|
+
resource,
|
|
348
|
+
headers: this.buildHeaders(),
|
|
349
|
+
body: this.buildBody(dataSource, dimensions, window),
|
|
350
|
+
signal
|
|
351
|
+
});
|
|
352
|
+
return res.body;
|
|
353
|
+
}
|
|
354
|
+
async fetchInstallBuckets(window, signal) {
|
|
355
|
+
const dims = [CHANNEL_DIMENSION, CAMPAIGN_DIMENSION];
|
|
356
|
+
const rowsByDataSource = {
|
|
357
|
+
eo_install: [],
|
|
358
|
+
eo_open: [],
|
|
359
|
+
eo_event: []
|
|
360
|
+
};
|
|
361
|
+
for (const dataSource of INSTALL_DATA_SOURCES) {
|
|
362
|
+
const field = COUNT_FIELD_BY_DATA_SOURCE[dataSource];
|
|
363
|
+
const tag = `install_metrics_${field}`;
|
|
364
|
+
const body = await this.fetchAggregate(
|
|
365
|
+
tag,
|
|
366
|
+
dataSource,
|
|
367
|
+
dims,
|
|
368
|
+
window,
|
|
369
|
+
signal
|
|
370
|
+
);
|
|
371
|
+
rowsByDataSource[dataSource] = body.results ?? [];
|
|
372
|
+
}
|
|
373
|
+
return mergeInstallBuckets(rowsByDataSource);
|
|
374
|
+
}
|
|
375
|
+
async fetchClickRows(window, signal) {
|
|
376
|
+
const body = await this.fetchAggregate(
|
|
377
|
+
"deep_link_events",
|
|
378
|
+
"eo_click",
|
|
379
|
+
[CHANNEL_DIMENSION, CAMPAIGN_DIMENSION, FEATURE_DIMENSION],
|
|
380
|
+
window,
|
|
381
|
+
signal
|
|
382
|
+
);
|
|
383
|
+
return body.results ?? [];
|
|
384
|
+
}
|
|
385
|
+
async writePhase(storage, phase, window, signal) {
|
|
386
|
+
if (phase === "install_metrics") {
|
|
387
|
+
const buckets = await this.fetchInstallBuckets(window, signal);
|
|
388
|
+
await storage.metrics([], { names: [INSTALL_METRIC_NAME] });
|
|
389
|
+
for (const bucket of buckets) {
|
|
390
|
+
await storage.metric(installBucketToMetricSample(bucket));
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const rows = await this.fetchClickRows(window, signal);
|
|
395
|
+
for (const row of rows) {
|
|
396
|
+
await storage.event(clickRowToEventRecord(row));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async sync(options, storage, signal) {
|
|
400
|
+
const cursor = isBranchSyncCursor(
|
|
401
|
+
options.cursor
|
|
402
|
+
) ? options.cursor : void 0;
|
|
403
|
+
const lookbackDays = this.settings.lookbackDays ?? DEFAULT_LOOKBACK_DAYS;
|
|
404
|
+
const window = getWindow(options, lookbackDays);
|
|
405
|
+
const phases = selectActivePhases(
|
|
406
|
+
(r) => r,
|
|
407
|
+
PHASE_ORDER,
|
|
408
|
+
this.settings.resources
|
|
409
|
+
);
|
|
410
|
+
return paginateChunked({
|
|
411
|
+
phases,
|
|
412
|
+
cursor,
|
|
413
|
+
signal,
|
|
414
|
+
logger: this.logger,
|
|
415
|
+
fetchPage: async (_phase, _page, _sig) => ({ items: [null], next: null }),
|
|
416
|
+
writeBatch: async (phase, _items, _page) => {
|
|
417
|
+
await this.writePhase(storage, phase, window, signal);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// src/index.ts
|
|
424
|
+
var index_default = BranchConnector;
|
|
425
|
+
export {
|
|
426
|
+
BranchConnector,
|
|
427
|
+
clickRowToEventRecord,
|
|
428
|
+
configFields,
|
|
429
|
+
index_default as default,
|
|
430
|
+
doc,
|
|
431
|
+
getWindow,
|
|
432
|
+
id,
|
|
433
|
+
installBucketToMetricSample,
|
|
434
|
+
mergeInstallBuckets,
|
|
435
|
+
branchResources as resources
|
|
436
|
+
};
|
|
437
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../connector-shared/src/errors.ts","../../../connector-shared/src/retry.ts","../../../connector-shared/src/version.ts","../../../connector-shared/src/request.ts","../../../connector-shared/src/rate-limit.ts","../../../connector-shared/src/map-concurrent.ts","../../../connector-shared/src/sanitize.ts","../../../connector-shared/src/epoch.ts","../../../connector-shared/src/pagination.ts","../../../connector-shared/src/logger.ts","../src/branch.ts","../src/index.ts"],"sourcesContent":["import type { HttpResponse } from './types';\n\nexport type HttpErrorKind =\n | 'transient'\n | 'rate_limit'\n | 'auth'\n | 'upstream_bug'\n | 'client_bug';\n\nexport abstract class HttpClientError extends Error {\n abstract readonly kind: HttpErrorKind;\n readonly response?: HttpResponse;\n\n constructor(message: string, response?: HttpResponse) {\n super(message);\n this.name = new.target.name;\n this.response = response;\n }\n}\n\nexport class TransientError extends HttpClientError {\n readonly kind = 'transient' as const;\n}\n\nexport class RateLimitError extends HttpClientError {\n readonly kind = 'rate_limit' as const;\n readonly retryAfter?: Date;\n\n constructor(message: string, response?: HttpResponse, retryAfter?: Date) {\n super(message, response);\n this.retryAfter = retryAfter;\n }\n}\n\nexport class AuthError extends HttpClientError {\n readonly kind = 'auth' as const;\n}\n\nexport class UpstreamBugError extends HttpClientError {\n readonly kind = 'upstream_bug' as const;\n}\n\nexport class ClientBugError extends HttpClientError {\n readonly kind = 'client_bug' as const;\n}\n\nexport function classifyStatus(status: number): HttpErrorKind {\n if (status === 429) {\n return 'rate_limit';\n }\n if (status === 401 || status === 403) {\n return 'auth';\n }\n if (status === 408) {\n return 'transient';\n }\n if (status >= 500) {\n return 'upstream_bug';\n }\n if (status >= 400) {\n return 'client_bug';\n }\n return 'client_bug';\n}\n\nexport function errorForStatus(\n message: string,\n response: HttpResponse,\n retryAfter?: Date,\n): HttpClientError {\n const kind = classifyStatus(response.status);\n switch (kind) {\n case 'rate_limit':\n return new RateLimitError(message, response, retryAfter);\n case 'auth':\n return new AuthError(message, response);\n case 'transient':\n return new TransientError(message, response);\n case 'upstream_bug':\n return new UpstreamBugError(message, response);\n case 'client_bug':\n return new ClientBugError(message, response);\n }\n}\n","import { HttpClientError, RateLimitError, TransientError } from './errors';\n\nexport interface RetryPolicy {\n maxAttempts?: number;\n initialDelayMs?: number;\n maxDelayMs?: number;\n retryOn?: (status: number | null, err?: Error) => boolean;\n}\n\nexport const defaultRetryOn = (status: number | null, err?: Error): boolean => {\n if (err instanceof RateLimitError) {\n return true;\n }\n if (err instanceof TransientError) {\n return true;\n }\n if (status === null) {\n return err instanceof Error && !(err instanceof HttpClientError);\n }\n if (status === 408 || status === 429) {\n return true;\n }\n if (status >= 500) {\n return true;\n }\n return false;\n};\n\nexport function backoffDelayMs(\n attempt: number,\n policy: Required<Pick<RetryPolicy, 'initialDelayMs' | 'maxDelayMs'>>,\n): number {\n const base = policy.initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, policy.maxDelayMs);\n}\n\nexport function parseRetryAfter(\n headerValue: string | null,\n now: Date = new Date(),\n): Date | undefined {\n if (!headerValue) {\n return undefined;\n }\n const trimmed = headerValue.trim();\n if (/^\\d+$/.test(trimmed)) {\n return new Date(now.getTime() + Number(trimmed) * 1000);\n }\n const parsed = Date.parse(trimmed);\n if (Number.isNaN(parsed)) {\n return undefined;\n }\n return new Date(parsed);\n}\n\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(signal.reason ?? new Error('Aborted'));\n }\n return new Promise<void>((resolve, reject) => {\n const onAbort = () => {\n clearTimeout(timer);\n reject(signal!.reason ?? new Error('Aborted'));\n };\n const timer = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n","export const HTTP_CLIENT_VERSION = '0.0.0';\n\nexport const DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n\nexport function connectorUserAgent(connectorId: string): string {\n return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n}\n","import {\n AuthError,\n ClientBugError,\n HttpClientError,\n RateLimitError,\n TransientError,\n UpstreamBugError,\n errorForStatus,\n} from './errors';\nimport { defaultRetryOn, parseRetryAfter, sleep } from './retry';\nimport type { FetchLike, HttpMethod, HttpRequest, HttpResponse } from './types';\nimport { DEFAULT_USER_AGENT } from './version';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\nconst DEFAULT_INITIAL_DELAY_MS = 1000;\nconst DEFAULT_MAX_DELAY_MS = 60_000;\nconst OBSERVER_TIMEOUT_MS = 250;\n\nexport interface RequestObservation {\n url: string;\n method: HttpMethod;\n status: number;\n resource: string;\n requestId: string;\n body: unknown;\n}\n\nexport type RequestObserver = (\n event: RequestObservation,\n) => void | Promise<void>;\n\nexport interface RequestOptions {\n fetch?: FetchLike;\n observer?: RequestObserver;\n resource: string;\n requestId?: string;\n}\n\nasync function notifyObserver(\n observer: RequestObserver,\n event: RequestObservation,\n): Promise<void> {\n let result: void | Promise<void>;\n try {\n result = observer(event);\n } catch (err) {\n console.warn('[connector-shared] request observer threw:', err);\n return;\n }\n if (!(result instanceof Promise)) {\n return;\n }\n const guarded = result.catch((err) => {\n console.warn('[connector-shared] request observer rejected:', err);\n });\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<void>((resolve) => {\n timer = setTimeout(resolve, OBSERVER_TIMEOUT_MS);\n });\n try {\n await Promise.race([guarded, timeout]);\n } finally {\n if (timer) {\n clearTimeout(timer);\n }\n }\n}\n\nfunction newRequestId(): string {\n const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (c?.randomUUID) {\n return c.randomUUID();\n }\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction mergeHeaders(\n defaults: Record<string, string>,\n overrides: Record<string, string> | undefined,\n): Record<string, string> {\n const merged: Record<string, string> = {};\n for (const [k, v] of Object.entries(defaults)) {\n merged[k.toLowerCase()] = v;\n }\n if (overrides) {\n for (const [k, v] of Object.entries(overrides)) {\n merged[k.toLowerCase()] = v;\n }\n }\n return merged;\n}\n\nfunction linkTimeoutSignal(\n parent: AbortSignal | undefined,\n timeoutMs: number,\n): { signal: AbortSignal; cancel: () => void } {\n const controller = new AbortController();\n const onParentAbort = () => {\n controller.abort(parent?.reason);\n };\n if (parent) {\n if (parent.aborted) {\n controller.abort(parent.reason);\n } else {\n parent.addEventListener('abort', onParentAbort, { once: true });\n }\n }\n const timer = setTimeout(() => {\n controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n return {\n signal: controller.signal,\n cancel: () => {\n clearTimeout(timer);\n if (parent) {\n parent.removeEventListener('abort', onParentAbort);\n }\n },\n };\n}\n\nasync function readBody(res: Response, parseJson: boolean): Promise<unknown> {\n if (res.status === 204 || res.status === 205) {\n return null;\n }\n const contentType = res.headers.get('content-type') ?? '';\n if (parseJson && contentType.includes('application/json')) {\n const text = await res.text();\n if (text.length === 0) {\n return null;\n }\n return JSON.parse(text);\n }\n return res.text();\n}\n\nexport async function request<T = unknown>(\n req: HttpRequest,\n options: RequestOptions,\n): Promise<HttpResponse<T>> {\n const fetchImpl: FetchLike = options.fetch ?? (globalThis.fetch as FetchLike);\n const retry = req.retry ?? {};\n const maxAttempts = retry.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const initialDelayMs = retry.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;\n const maxDelayMs = retry.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;\n const retryOn = retry.retryOn ?? defaultRetryOn;\n const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const parseJson = req.parseJson ?? true;\n\n const headers = mergeHeaders(\n {\n 'User-Agent': DEFAULT_USER_AGENT,\n Accept: 'application/json',\n },\n req.headers,\n );\n\n let lastErr: Error | undefined;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n req.signal?.throwIfAborted();\n\n const { signal, cancel } = linkTimeoutSignal(req.signal, timeoutMs);\n let res: Response;\n try {\n res = await fetchImpl(req.url, {\n method: req.method ?? 'GET',\n headers,\n body: req.body as RequestInit['body'],\n signal,\n });\n } catch (err) {\n cancel();\n if (req.signal?.aborted) {\n throw req.signal.reason ?? err;\n }\n const error = err instanceof Error ? err : new Error(String(err));\n lastErr = error;\n if (attempt < maxAttempts - 1 && retryOn(null, error)) {\n const delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n await sleep(delay, req.signal);\n continue;\n }\n throw new TransientError(error.message);\n }\n cancel();\n\n const body = await readBody(res, parseJson);\n const httpResponse: HttpResponse<T> = {\n status: res.status,\n headers: res.headers,\n body: body as T,\n };\n if (req.rateLimit) {\n const state = req.rateLimit.parse(res.headers);\n if (state) {\n httpResponse.rateLimitState = state;\n }\n }\n\n if (options.observer) {\n await notifyObserver(options.observer, {\n url: req.url,\n method: req.method ?? 'GET',\n status: res.status,\n resource: options.resource,\n requestId: options.requestId ?? newRequestId(),\n body,\n });\n }\n\n if (res.ok) {\n return httpResponse;\n }\n\n const retryAfter = parseRetryAfter(res.headers.get('retry-after'));\n const message = `HTTP ${res.status} ${res.statusText} for ${req.method ?? 'GET'} ${req.url}`;\n const err = errorForStatus(message, httpResponse, retryAfter);\n\n if (\n attempt < maxAttempts - 1 &&\n retryOn(res.status, err) &&\n !(err instanceof AuthError) &&\n !(err instanceof ClientBugError)\n ) {\n lastErr = err;\n let delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n if (err instanceof RateLimitError && retryAfter) {\n const wait = retryAfter.getTime() - Date.now();\n if (wait > 0) {\n delay = Math.min(wait, maxDelayMs);\n }\n }\n await sleep(delay, req.signal);\n continue;\n }\n\n throw err;\n }\n\n throw lastErr ?? new UpstreamBugError('Exhausted retry attempts');\n}\n\nfunction computeDelay(\n attempt: number,\n initialDelayMs: number,\n maxDelayMs: number,\n): number {\n const base = initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, maxDelayMs);\n}\n\nexport { HttpClientError };\n","export interface RateLimitState {\n remaining: number;\n resetAt: Date;\n}\n\nexport interface RateLimitPolicy {\n parse(headers: Headers): RateLimitState | null;\n}\n\nexport interface StandardRateLimitPolicyConfig {\n remainingHeader: string;\n resetHeader: string;\n resetUnit: 's' | 'ms';\n resetFallbackMs?: number;\n}\n\nexport function standardRateLimitPolicy(\n config: StandardRateLimitPolicyConfig,\n): RateLimitPolicy {\n const { remainingHeader, resetHeader, resetUnit, resetFallbackMs } = config;\n const multiplier = resetUnit === 's' ? 1000 : 1;\n return {\n parse(h) {\n const remainingRaw = h.get(remainingHeader);\n if (remainingRaw === null || remainingRaw.trim() === '') {\n return null;\n }\n const remaining = Number(remainingRaw);\n if (!Number.isFinite(remaining)) {\n return null;\n }\n const resetRaw = h.get(resetHeader);\n if (resetRaw === null) {\n if (resetFallbackMs === undefined) {\n return null;\n }\n return {\n remaining,\n resetAt: new Date(Date.now() + resetFallbackMs),\n };\n }\n if (resetRaw.trim() === '') {\n return null;\n }\n const reset = Number(resetRaw);\n if (!Number.isFinite(reset) || reset < 0) {\n return null;\n }\n const resetMs = reset * multiplier;\n if (!Number.isFinite(resetMs)) {\n return null;\n }\n return { remaining, resetAt: new Date(resetMs) };\n },\n };\n}\n","export async function mapWithConcurrency<T, R>(\n items: readonly T[],\n concurrency: number,\n fn: (item: T, index: number) => Promise<R>,\n): Promise<R[]> {\n const results = new Array<R>(items.length);\n if (items.length === 0) {\n return results;\n }\n const normalized = Number.isFinite(concurrency) ? Math.floor(concurrency) : 1;\n const limit = Math.max(1, Math.min(normalized, items.length));\n let next = 0;\n let failed = false;\n\n async function worker(): Promise<void> {\n while (!failed) {\n const i = next++;\n if (i >= items.length) {\n return;\n }\n try {\n results[i] = await fn(items[i]!, i);\n } catch (err) {\n failed = true;\n throw err;\n }\n }\n }\n\n const workers: Promise<void>[] = [];\n for (let w = 0; w < limit; w++) {\n workers.push(worker());\n }\n await Promise.all(workers);\n return results;\n}\n","export interface SanitizeAllowedUrlOptions {\n url: string | null;\n host: string;\n pathname: string;\n protocol?: 'https:' | 'http:';\n}\n\nexport function sanitizeAllowedUrl(\n options: SanitizeAllowedUrlOptions,\n): string | null {\n const { url, host, pathname, protocol = 'https:' } = options;\n if (url === null) {\n return null;\n }\n try {\n const u = new URL(url);\n if (u.protocol !== protocol || u.host !== host || u.pathname !== pathname) {\n return null;\n }\n return u.toString();\n } catch {\n return null;\n }\n}\n","export type EpochUnit = 'ms' | 's' | 'iso';\n\nexport function parseEpoch(\n value: number | string | null | undefined,\n unit: EpochUnit,\n): number | null {\n if (value === null || value === undefined) {\n return null;\n }\n if (unit === 'iso') {\n if (typeof value !== 'string') {\n return null;\n }\n const ms = new Date(value).getTime();\n return Number.isFinite(ms) ? ms : null;\n }\n if (typeof value === 'string' && value.trim() === '') {\n return null;\n }\n const n = typeof value === 'number' ? value : Number(value);\n if (!Number.isFinite(n)) {\n return null;\n }\n const result = unit === 's' ? n * 1000 : n;\n return Number.isFinite(result) ? result : null;\n}\n","import { request } from './request';\nimport type { HttpRequest } from './types';\n\nexport function parseLinkHeader(header: string | null): Record<string, string> {\n if (!header) {\n return {};\n }\n const result: Record<string, string> = {};\n for (const part of header.split(',')) {\n const match = part.match(/<([^>]+)>\\s*;\\s*rel=\"([^\"]+)\"/);\n if (match) {\n result[match[2]!] = match[1]!;\n }\n }\n return result;\n}\n\nexport async function* paginateLink<T>(\n initial: HttpRequest,\n parse: (body: unknown) => T[],\n options: { resource: string },\n): AsyncIterable<T> {\n let next: string | null = initial.url;\n while (next) {\n const res: Awaited<ReturnType<typeof request>> = await request(\n {\n ...initial,\n url: next,\n },\n { resource: options.resource },\n );\n for (const item of parse(res.body)) {\n yield item;\n }\n const links = parseLinkHeader(res.headers.get('link'));\n next = links['next'] ?? null;\n }\n}\n\nexport async function* paginateCursor<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; nextCursor: string | null },\n buildNext: (req: HttpRequest, cursor: string) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let req: HttpRequest = initial;\n while (true) {\n const res = await request(req, { resource: options.resource });\n const { items, nextCursor } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!nextCursor) {\n return;\n }\n req = buildNext(req, nextCursor);\n }\n}\n\nexport async function* paginatePage<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; hasMore: boolean },\n buildPage: (req: HttpRequest, page: number) => HttpRequest,\n options: { resource: string },\n): AsyncIterable<T> {\n let page = 1;\n while (true) {\n const req = page === 1 ? initial : buildPage(initial, page);\n const res = await request(req, { resource: options.resource });\n const { items, hasMore } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!hasMore || items.length === 0) {\n return;\n }\n page++;\n }\n}\n","export type LogFields = Record<string, unknown>;\n\nexport interface ConnectorLogger {\n info(event: string, fields?: LogFields): void;\n warn(event: string, fields?: LogFields): void;\n}\n\nexport interface ConnectorLoggerOptions {\n scope: string;\n}\n\nconst MAX_VALUE_LEN = 120;\n\nfunction truncate(s: string, max = MAX_VALUE_LEN): string {\n if (s.length <= max) {\n return s;\n }\n return `${s.slice(0, max - 1)}…`;\n}\n\nfunction formatValue(value: unknown): string {\n if (value === null) {\n return 'null';\n }\n if (value === undefined) {\n return '';\n }\n if (typeof value === 'number' || typeof value === 'boolean') {\n return String(value);\n }\n if (typeof value === 'string') {\n const t = truncate(value);\n if (/[\\s\"=]/.test(t)) {\n return JSON.stringify(t);\n }\n return t;\n }\n if (typeof value === 'bigint') {\n return value.toString();\n }\n let json: string | undefined;\n try {\n json = JSON.stringify(value);\n } catch {\n json = undefined;\n }\n return truncate(json ?? String(value));\n}\n\nexport function formatLogFields(fields?: LogFields): string {\n if (!fields) {\n return '';\n }\n const parts: string[] = [];\n for (const [k, v] of Object.entries(fields)) {\n if (v === undefined) {\n continue;\n }\n parts.push(`${k}=${formatValue(v)}`);\n }\n return parts.length > 0 ? ` ${parts.join(' ')}` : '';\n}\n\nexport function formatLogLine(\n scope: string,\n event: string,\n fields?: LogFields,\n): string {\n return `[${scope}] ${event}${formatLogFields(fields)}`;\n}\n\nexport function createDefaultConnectorLogger(\n opts: ConnectorLoggerOptions,\n): ConnectorLogger {\n return {\n info(event, fields) {\n console.info(formatLogLine(opts.scope, event, fields));\n },\n warn(event, fields) {\n console.warn(formatLogLine(opts.scope, event, fields));\n },\n };\n}\n\nconst NOOP_LOGGER: ConnectorLogger = {\n info() {},\n warn() {},\n};\n\nexport function noopConnectorLogger(): ConnectorLogger {\n return NOOP_LOGGER;\n}\n","import { connectorUserAgent } from '@rawdash/connector-shared';\nimport {\n BaseConnector,\n type ChunkedSyncCursor,\n type ConnectorContext,\n type ConnectorDoc,\n type CredentialsSchema,\n type Event,\n type MetricSample,\n type StorageHandle,\n type SyncOptions,\n type SyncResult,\n defineConfigFields,\n defineConnectorDoc,\n defineResources,\n makeChunkedCursorGuard,\n paginateChunked,\n schemasFromResources,\n selectActivePhases,\n} from '@rawdash/core';\nimport { z } from 'zod';\n\nexport const configFields = defineConfigFields(\n z.object({\n branchKey: z.object({ $secret: z.string() }).meta({\n label: 'Branch key',\n description:\n 'Your Branch app key (starts with `key_live_`). Find it in the Branch dashboard under Account Settings -> Profile.',\n placeholder: 'key_live_xxxxxxxxxxxxxxxxxxxxxxxxxx',\n secret: true,\n }),\n branchSecret: z.object({ $secret: z.string() }).meta({\n label: 'Branch secret',\n description:\n 'Your Branch app secret (starts with `secret_live_`). Find it next to the key in the Branch dashboard.',\n placeholder: 'secret_live_xxxxxxxxxxxxxxxxxxxxxxxxxx',\n secret: true,\n }),\n lookbackDays: z.number().int().positive().optional().meta({\n label: 'Lookback days (full sync)',\n description:\n 'How many calendar days of metrics/events to fetch on a full sync. Defaults to 90.',\n placeholder: '90',\n }),\n resources: z\n .array(z.enum(['install_metrics', 'deep_link_events']))\n .nonempty()\n .optional()\n .meta({\n label: 'Resources',\n description:\n 'Which Branch resources to sync. Omit to sync all of them.',\n }),\n }),\n);\n\nexport const doc: ConnectorDoc = defineConnectorDoc({\n displayName: 'Branch',\n category: 'marketing',\n brandColor: '#7CB833',\n tagline:\n 'Sync Branch install attribution metrics (installs, opens, conversions) and deep-link click events from the Cross-Platform Analytics API for mobile attribution dashboards.',\n vendor: {\n name: 'Branch',\n domain: 'branch.io',\n apiDocs: 'https://help.branch.io/developers-hub/reference',\n website: 'https://www.branch.io',\n },\n auth: {\n summary:\n 'A Branch app key and secret, used together to authenticate Cross-Platform Analytics API requests.',\n setup: [\n 'In the Branch dashboard, open Account Settings -> Profile and copy the Branch Key (starts with `key_live_`).',\n 'On the same screen, reveal and copy the Branch Secret (starts with `secret_live_`). Both values are app-scoped; keep them in a secret store.',\n 'Reference them from the connector config as `branchKey: secret(\"BRANCH_KEY\")` and `branchSecret: secret(\"BRANCH_SECRET\")`.',\n ],\n },\n rateLimit:\n 'Branch enforces a per-app request quota on the Cross-Platform Analytics API (roughly 1 request/second). The connector issues one POST per data source per resource per sync and respects 429 + Retry-After backoff via the shared HTTP client.',\n limitations: [\n 'Daily granularity only - the connector requests `granularity=day` from the Branch Aggregate API to keep result cardinality bounded.',\n 'Cost attribution is best-effort - Branch only exposes `cost_in_local_currency` for ad-network-integrated channels. Rows without cost data carry `costEstimated: 0`.',\n 'Deep-link events are aggregated daily click counts per (date, channel, campaign, feature). Individual click-level records require the Branch Daily Export API which is intentionally out of scope.',\n ],\n});\n\nexport interface BranchSettings {\n lookbackDays?: number;\n resources?: readonly BranchResource[];\n}\n\nconst branchCredentials = {\n branchKey: {\n description: 'Branch app key (key_live_...)',\n auth: 'required' as const,\n },\n branchSecret: {\n description: 'Branch app secret (secret_live_...)',\n auth: 'required' as const,\n },\n} satisfies CredentialsSchema;\n\ntype BranchCredentials = typeof branchCredentials;\n\nconst PHASE_ORDER = ['install_metrics', 'deep_link_events'] as const;\n\ntype BranchPhase = (typeof PHASE_ORDER)[number];\n\nexport type BranchResource = BranchPhase;\n\ntype BranchSyncCursor = ChunkedSyncCursor<BranchPhase, string>;\n\nconst isBranchSyncCursor = makeChunkedCursorGuard(PHASE_ORDER);\n\nconst ANALYTICS_API_URL = 'https://api2.branch.io/v1/query/analytics';\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\nconst DEFAULT_LOOKBACK_DAYS = 90;\nconst INCREMENTAL_LOOKBACK_DAYS = 14;\n\nconst INSTALL_METRIC_NAME = 'branch_install_metrics';\nconst DEEP_LINK_EVENT_NAME = 'branch_deep_link_event';\n\nconst CHANNEL_DIMENSION = 'last_attributed_touch_data_tilde_channel';\nconst CAMPAIGN_DIMENSION = 'last_attributed_touch_data_tilde_campaign';\nconst FEATURE_DIMENSION = 'last_attributed_touch_data_tilde_feature';\n\nconst INSTALL_DATA_SOURCES = ['eo_install', 'eo_open', 'eo_event'] as const;\ntype InstallDataSource = (typeof INSTALL_DATA_SOURCES)[number];\n\nconst COUNT_FIELD_BY_DATA_SOURCE: Record<InstallDataSource, string> = {\n eo_install: 'installs',\n eo_open: 'opens',\n eo_event: 'conversions',\n};\n\nconst isoDateString = z.string().regex(/^\\d{4}-\\d{2}-\\d{2}$/);\nconst numericLike = z.union([z.number(), z.string(), z.null()]).optional();\n\nconst installResultRowSchema = z.object({\n unique_count: numericLike,\n result: z.object({\n timestamp: isoDateString,\n [CHANNEL_DIMENSION]: z.string().nullish(),\n [CAMPAIGN_DIMENSION]: z.string().nullish(),\n cost_in_local_currency: numericLike,\n }),\n});\n\nconst installResponseSchema = z.object({\n results: z.array(installResultRowSchema),\n});\n\nconst clickResultRowSchema = z.object({\n unique_count: numericLike,\n result: z.object({\n timestamp: isoDateString,\n [CHANNEL_DIMENSION]: z.string().nullish(),\n [CAMPAIGN_DIMENSION]: z.string().nullish(),\n [FEATURE_DIMENSION]: z.string().nullish(),\n }),\n});\n\nconst clickResponseSchema = z.object({\n results: z.array(clickResultRowSchema),\n});\n\nexport const branchResources = defineResources({\n [INSTALL_METRIC_NAME]: {\n shape: 'metric',\n description:\n 'Daily Branch attribution metrics bucketed by channel and campaign. Primary value is `installs`; `opens`, `conversions`, and `costEstimated` are carried as attributes.',\n endpoint: 'POST /v1/query/analytics',\n unit: 'installs',\n granularity: 'day',\n notes:\n 'Merges three Aggregate API calls (data_source=eo_install, eo_open, eo_event) keyed by (date, channel, campaign). Rows with missing channel or campaign are recorded as `null` for that attribute.',\n dimensions: [\n { name: 'date', description: 'Calendar day of the metric sample (UTC).' },\n { name: 'channel', description: 'Branch last-attributed channel.' },\n { name: 'campaign', description: 'Branch last-attributed campaign.' },\n { name: 'installs', description: 'Attributed installs on the day.' },\n { name: 'opens', description: 'Attributed app opens on the day.' },\n {\n name: 'conversions',\n description: 'Attributed in-app conversion events on the day.',\n },\n {\n name: 'costEstimated',\n description:\n 'Estimated cost in the app local currency (only populated for ad-network-integrated channels; 0 otherwise).',\n },\n ],\n responses: {\n install_metrics_installs: installResponseSchema,\n install_metrics_opens: installResponseSchema,\n install_metrics_conversions: installResponseSchema,\n },\n },\n [DEEP_LINK_EVENT_NAME]: {\n shape: 'event',\n description:\n 'Daily aggregated Branch deep-link click events bucketed by channel, campaign, and feature. One event per (date, channel, campaign, feature) row carrying the daily click count.',\n endpoint: 'POST /v1/query/analytics',\n notes:\n 'Sourced from data_source=eo_click. Event id encodes the bucket so resyncs are idempotent.',\n fields: [\n { name: 'date', description: 'Calendar day of the click bucket (UTC).' },\n { name: 'channel', description: 'Branch last-attributed channel.' },\n { name: 'campaign', description: 'Branch last-attributed campaign.' },\n {\n name: 'feature',\n description: 'Branch last-attributed feature (e.g. `sharing`).',\n },\n { name: 'clicks', description: 'Click count for the bucket.' },\n ],\n responses: { deep_link_events: clickResponseSchema },\n },\n});\n\nexport type BranchInstallResultRow = z.infer<typeof installResultRowSchema>;\nexport type BranchClickResultRow = z.infer<typeof clickResultRowSchema>;\n\ninterface BranchWindow {\n from: string;\n to: string;\n}\n\nfunction pad2(n: number): string {\n return String(n).padStart(2, '0');\n}\n\nfunction toIsoDate(ms: number): string {\n const d = new Date(ms);\n return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;\n}\n\nfunction startOfUtcDay(ms: number): number {\n return Math.floor(ms / MS_PER_DAY) * MS_PER_DAY;\n}\n\nexport function getWindow(\n options: SyncOptions,\n lookbackDays: number,\n now: number = Date.now(),\n): BranchWindow {\n const today = startOfUtcDay(now);\n if (options.mode === 'latest') {\n return {\n from: toIsoDate(today - (INCREMENTAL_LOOKBACK_DAYS - 1) * MS_PER_DAY),\n to: toIsoDate(today),\n };\n }\n if (options.since) {\n const sinceMs = new Date(options.since).getTime();\n if (Number.isFinite(sinceMs)) {\n const requested = Math.max(\n 1,\n Math.ceil((today - startOfUtcDay(sinceMs)) / MS_PER_DAY) + 1,\n );\n const capped = Math.min(requested, lookbackDays);\n return {\n from: toIsoDate(today - (capped - 1) * MS_PER_DAY),\n to: toIsoDate(today),\n };\n }\n }\n return {\n from: toIsoDate(today - (lookbackDays - 1) * MS_PER_DAY),\n to: toIsoDate(today),\n };\n}\n\nfunction isoDateToMs(date: string): number {\n const [y, m, d] = date.split('-').map((part) => Number(part));\n if (\n y === undefined ||\n m === undefined ||\n d === undefined ||\n !Number.isFinite(y) ||\n !Number.isFinite(m) ||\n !Number.isFinite(d)\n ) {\n return NaN;\n }\n return Date.UTC(y, m - 1, d);\n}\n\nfunction parseNumber(value: unknown): number {\n if (typeof value === 'number' && Number.isFinite(value)) {\n return value;\n }\n if (typeof value === 'string' && value.trim() !== '') {\n const n = Number(value);\n return Number.isFinite(n) ? n : 0;\n }\n return 0;\n}\n\nfunction normalizeDateBucket(timestamp: string): string {\n return timestamp.slice(0, 10);\n}\n\ninterface InstallBucket {\n date: string;\n channel: string | null;\n campaign: string | null;\n installs: number;\n opens: number;\n conversions: number;\n costEstimated: number;\n}\n\nfunction bucketKey(\n date: string,\n channel: string | null,\n campaign: string | null,\n): string {\n return `${date}\u0001${channel ?? ''}\u0001${campaign ?? ''}`;\n}\n\nexport function mergeInstallBuckets(\n rowsByDataSource: Record<InstallDataSource, BranchInstallResultRow[]>,\n): InstallBucket[] {\n const buckets = new Map<string, InstallBucket>();\n for (const dataSource of INSTALL_DATA_SOURCES) {\n const field = COUNT_FIELD_BY_DATA_SOURCE[dataSource];\n for (const row of rowsByDataSource[dataSource]) {\n const date = normalizeDateBucket(row.result.timestamp);\n const channel =\n (row.result[CHANNEL_DIMENSION] as string | null | undefined) ?? null;\n const campaign =\n (row.result[CAMPAIGN_DIMENSION] as string | null | undefined) ?? null;\n const key = bucketKey(date, channel, campaign);\n let bucket = buckets.get(key);\n if (!bucket) {\n bucket = {\n date,\n channel,\n campaign,\n installs: 0,\n opens: 0,\n conversions: 0,\n costEstimated: 0,\n };\n buckets.set(key, bucket);\n }\n const count = parseNumber(row.unique_count);\n if (field === 'installs') {\n bucket.installs += count;\n } else if (field === 'opens') {\n bucket.opens += count;\n } else {\n bucket.conversions += count;\n }\n if (dataSource === 'eo_install') {\n bucket.costEstimated += parseNumber(row.result.cost_in_local_currency);\n }\n }\n }\n return Array.from(buckets.values()).sort((a, b) =>\n a.date < b.date ? -1 : a.date > b.date ? 1 : 0,\n );\n}\n\nexport function installBucketToMetricSample(\n bucket: InstallBucket,\n): MetricSample {\n const ts = isoDateToMs(bucket.date);\n return {\n name: INSTALL_METRIC_NAME,\n ts: Number.isFinite(ts) ? ts : 0,\n value: bucket.installs,\n attributes: {\n date: bucket.date,\n channel: bucket.channel,\n campaign: bucket.campaign,\n installs: bucket.installs,\n opens: bucket.opens,\n conversions: bucket.conversions,\n costEstimated: bucket.costEstimated,\n },\n };\n}\n\nexport function clickRowToEventRecord(row: BranchClickResultRow): Event {\n const date = normalizeDateBucket(row.result.timestamp);\n const channel =\n (row.result[CHANNEL_DIMENSION] as string | null | undefined) ?? null;\n const campaign =\n (row.result[CAMPAIGN_DIMENSION] as string | null | undefined) ?? null;\n const feature =\n (row.result[FEATURE_DIMENSION] as string | null | undefined) ?? null;\n const ts = isoDateToMs(date);\n const clicks = parseNumber(row.unique_count);\n const startTs = Number.isFinite(ts) ? ts : 0;\n return {\n name: DEEP_LINK_EVENT_NAME,\n start_ts: startTs,\n end_ts: startTs,\n attributes: {\n bucketKey: `${date}|${channel ?? ''}|${campaign ?? ''}|${feature ?? ''}`,\n date,\n channel,\n campaign,\n feature,\n clicks,\n },\n };\n}\n\nexport const id = 'branch';\n\nexport class BranchConnector extends BaseConnector<\n BranchSettings,\n BranchCredentials\n> {\n static readonly id = id;\n\n static readonly resources = branchResources;\n\n static readonly schemas = schemasFromResources(branchResources);\n\n static create(input: unknown, ctx?: ConnectorContext): BranchConnector {\n const parsed = configFields.parse(input);\n return new BranchConnector(\n { lookbackDays: parsed.lookbackDays, resources: parsed.resources },\n { branchKey: parsed.branchKey, branchSecret: parsed.branchSecret },\n ctx,\n );\n }\n\n readonly id = id;\n override readonly credentials = branchCredentials;\n\n private buildHeaders(): Record<string, string> {\n return {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n 'User-Agent': connectorUserAgent('branch'),\n };\n }\n\n private buildBody(\n dataSource: string,\n dimensions: string[],\n window: BranchWindow,\n ): string {\n return JSON.stringify({\n branch_key: this.creds.branchKey,\n branch_secret: this.creds.branchSecret,\n start_date: window.from,\n end_date: window.to,\n data_source: dataSource,\n dimensions,\n granularity: 'day',\n aggregation: 'unique_count',\n ordered: 'ascending',\n ordered_by: 'timestamp',\n });\n }\n\n private async fetchAggregate<T>(\n resource: string,\n dataSource: string,\n dimensions: string[],\n window: BranchWindow,\n signal?: AbortSignal,\n ): Promise<{ results?: T[] }> {\n const res = await this.post<{ results?: T[] }>(ANALYTICS_API_URL, {\n resource,\n headers: this.buildHeaders(),\n body: this.buildBody(dataSource, dimensions, window),\n signal,\n });\n return res.body;\n }\n\n private async fetchInstallBuckets(\n window: BranchWindow,\n signal?: AbortSignal,\n ): Promise<InstallBucket[]> {\n const dims = [CHANNEL_DIMENSION, CAMPAIGN_DIMENSION];\n const rowsByDataSource = {\n eo_install: [] as BranchInstallResultRow[],\n eo_open: [] as BranchInstallResultRow[],\n eo_event: [] as BranchInstallResultRow[],\n };\n for (const dataSource of INSTALL_DATA_SOURCES) {\n const field = COUNT_FIELD_BY_DATA_SOURCE[dataSource];\n const tag = `install_metrics_${field}`;\n const body = await this.fetchAggregate<BranchInstallResultRow>(\n tag,\n dataSource,\n dims,\n window,\n signal,\n );\n rowsByDataSource[dataSource] = body.results ?? [];\n }\n return mergeInstallBuckets(rowsByDataSource);\n }\n\n private async fetchClickRows(\n window: BranchWindow,\n signal?: AbortSignal,\n ): Promise<BranchClickResultRow[]> {\n const body = await this.fetchAggregate<BranchClickResultRow>(\n 'deep_link_events',\n 'eo_click',\n [CHANNEL_DIMENSION, CAMPAIGN_DIMENSION, FEATURE_DIMENSION],\n window,\n signal,\n );\n return body.results ?? [];\n }\n\n private async writePhase(\n storage: StorageHandle,\n phase: BranchPhase,\n window: BranchWindow,\n signal?: AbortSignal,\n ): Promise<void> {\n if (phase === 'install_metrics') {\n const buckets = await this.fetchInstallBuckets(window, signal);\n await storage.metrics([], { names: [INSTALL_METRIC_NAME] });\n for (const bucket of buckets) {\n await storage.metric(installBucketToMetricSample(bucket));\n }\n return;\n }\n const rows = await this.fetchClickRows(window, signal);\n for (const row of rows) {\n await storage.event(clickRowToEventRecord(row));\n }\n }\n\n async sync(\n options: SyncOptions,\n storage: StorageHandle,\n signal?: AbortSignal,\n ): Promise<SyncResult> {\n const cursor: BranchSyncCursor | undefined = isBranchSyncCursor(\n options.cursor,\n )\n ? options.cursor\n : undefined;\n const lookbackDays = this.settings.lookbackDays ?? DEFAULT_LOOKBACK_DAYS;\n const window = getWindow(options, lookbackDays);\n\n const phases = selectActivePhases<BranchResource, BranchPhase>(\n (r) => r,\n PHASE_ORDER,\n this.settings.resources,\n );\n\n return paginateChunked<BranchPhase, string>({\n phases,\n cursor,\n signal,\n logger: this.logger,\n fetchPage: async (_phase, _page, _sig) => ({ items: [null], next: null }),\n writeBatch: async (phase, _items, _page) => {\n await this.writePhase(storage, phase, window, signal);\n },\n });\n }\n}\n","import { BranchConnector } from './branch';\n\nexport {\n BranchConnector,\n branchResources as resources,\n clickRowToEventRecord,\n configFields,\n doc,\n getWindow,\n id,\n installBucketToMetricSample,\n mergeInstallBuckets,\n} from './branch';\nexport type {\n BranchClickResultRow,\n BranchInstallResultRow,\n BranchResource,\n BranchSettings,\n} from './branch';\nexport default BranchConnector;\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rawdash/connector-branch",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Rawdash connector for Branch — syncs daily install/open/conversion attribution metrics and deep-link click events via the Branch Cross-Platform Analytics API",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/rawdash/rawdash.git",
|
|
11
|
+
"directory": "packages/connectors/branch"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"@rawdash/source": "./src/index.ts",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"lint": "eslint src",
|
|
29
|
+
"test": "vitest run"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@rawdash/core": "workspace:*",
|
|
33
|
+
"zod": "^4.4.3"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@rawdash/connector-shared": "workspace:*",
|
|
37
|
+
"@rawdash/connector-test-utils": "workspace:*",
|
|
38
|
+
"fast-check": "^4.8.0",
|
|
39
|
+
"tsup": "^8.0.0",
|
|
40
|
+
"typescript": "^5.7.2",
|
|
41
|
+
"vitest": "^4.1.4"
|
|
42
|
+
}
|
|
43
|
+
}
|