@formo/cli 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -26
- package/dist/commands/alerts.js +22 -1
- package/dist/commands/analytics.js +49 -1
- package/dist/commands/charts.js +2 -2
- package/dist/commands/profiles.d.ts +6 -6
- package/dist/commands/profiles.js +168 -39
- package/dist/commands/segments.d.ts +2 -2
- package/dist/commands/segments.js +41 -7
- package/dist/lib/filters.d.ts +6 -0
- package/dist/lib/filters.js +45 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -110,16 +110,16 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat
|
|
|
110
110
|
| `--order-by` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` |
|
|
111
111
|
| `--order-dir` | `asc` or `desc` |
|
|
112
112
|
| `--expand` | Comma-separated fields to expand |
|
|
113
|
-
| `--
|
|
114
|
-
| `--logic` | Combine
|
|
113
|
+
| `--filters` | JSON array of canonical `{field,op,value}` filter objects (see below) |
|
|
114
|
+
| `--logic` | Combine filters with `and` (default) or `or` |
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
117
|
formo profiles search --size 10
|
|
118
118
|
formo profiles search --order-by net_worth_usd --order-dir desc --size 5
|
|
119
119
|
formo profiles search --page 2 --size 20
|
|
120
|
-
formo profiles search --
|
|
121
|
-
formo profiles search --
|
|
122
|
-
formo profiles search --
|
|
120
|
+
formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20
|
|
121
|
+
formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]' --logic or --size 20
|
|
122
|
+
formo profiles search --filters '[{"field":"chains.balance","op":"gt","value":1000,"chain_id":"1"}]' --size 20
|
|
123
123
|
```
|
|
124
124
|
|
|
125
125
|
### Lifecycle tuning (advanced)
|
|
@@ -224,7 +224,7 @@ Get a single alert by ID.
|
|
|
224
224
|
|
|
225
225
|
```bash
|
|
226
226
|
formo alerts create --name "High value tx" --trigger-type event \
|
|
227
|
-
--trigger-filters '[{"
|
|
227
|
+
--trigger-filters '[{"field":"event","op":"eq","value":"transaction"}]' \
|
|
228
228
|
--recipient '[{"type":"email","value":["alerts@myapp.com"]}]'
|
|
229
229
|
```
|
|
230
230
|
|
|
@@ -308,10 +308,14 @@ formo charts create --board-id brd_123 --title "Daily Active Users" \
|
|
|
308
308
|
--x-axis date --y-axis users
|
|
309
309
|
|
|
310
310
|
formo charts create --board-id brd_123 --body '{"title":"Recent Events","chart_type":"table","query":"SELECT * FROM events LIMIT 10"}'
|
|
311
|
+
|
|
312
|
+
formo charts create --board-id brd_123 --title "Post-connect paths" \
|
|
313
|
+
--chart-type user_paths \
|
|
314
|
+
--settings '{"anchors":[{"type":"event","event":"connect"}],"maxSteps":5,"nodesPerStep":3}'
|
|
311
315
|
```
|
|
312
316
|
|
|
313
317
|
### `charts update <chartId> --board-id <boardId> [options]`
|
|
314
|
-
Update a chart. Accepts the same options as `create`: a raw `--body '<json>'` and/or typed flags (`--title`, `--chart-type`, `--query`, `--description`, `--x-axis`, `--y-axis`, `--group-by`, `--steps`, `--settings`). Typed flags override matching `--body` keys.
|
|
318
|
+
Update a chart. Accepts the same options as `create`: a raw `--body '<json>'` and/or typed flags (`--title`, `--chart-type`, `--query`, `--description`, `--x-axis`, `--y-axis`, `--group-by`, `--steps`, `--settings`). `--steps` is for funnel steps. User Paths use `--settings` with `anchors` and optional `maxSteps` / `nodesPerStep`; retention settings require an `entryFilter` key. Typed flags override matching `--body` keys.
|
|
315
319
|
|
|
316
320
|
```bash
|
|
317
321
|
formo charts update chart_abc123 --board-id brd_123 --title "Renamed chart"
|
|
@@ -389,7 +393,7 @@ List all user segments.
|
|
|
389
393
|
| Option | Description |
|
|
390
394
|
|---|---|
|
|
391
395
|
| `--title` | Segment title |
|
|
392
|
-
| `--
|
|
396
|
+
| `--filters` | JSON array of canonical `{field,op,value}` filter objects. Array string members cannot contain `|` |
|
|
393
397
|
|
|
394
398
|
### `segments delete <segmentId>`
|
|
395
399
|
Delete a user segment.
|
|
@@ -421,7 +425,7 @@ Pre-built analytics pipes — the same data that powers the Formo dashboard —
|
|
|
421
425
|
|---|---|
|
|
422
426
|
| `--date-from` | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--date-to`) |
|
|
423
427
|
| `--date-to` | Inclusive end date `YYYY-MM-DD` (default: today) |
|
|
424
|
-
| `--filters` | JSON array of `[{field,op,value}]`.
|
|
428
|
+
| `--filters` | JSON array of `[{field,op,value}]`. For `in`/`nin`, array values are preferred; pipe-delimited strings are also accepted. Array string members cannot contain `|` |
|
|
425
429
|
| `--params` | JSON object of pipe-specific params merged into the query (e.g. `{"limit":10,"group_by":"device"}`) |
|
|
426
430
|
|
|
427
431
|
```bash
|
|
@@ -434,6 +438,10 @@ formo analytics retention --filters '[{"field":"location","op":"eq","value":"US"
|
|
|
434
438
|
|
|
435
439
|
> Requires `query:read` scope. Run `formo analytics <pipe> --help` for the pipe-specific params accepted via `--params`.
|
|
436
440
|
|
|
441
|
+
On `kpis`, `top_*`, `revenue_*` and `volume_by_metric`, `--params '{"page_scope":"session"}'` widens a `page` filter from page-scoped metrics (the default) to the legacy session scope.
|
|
442
|
+
|
|
443
|
+
All user-attribute, profile, social, lifecycle and resource predicates go in the single `--filters` array, using the canonical envelope with named qualifiers (`chain_id`, `app_id`, `token_address`, `scope`, `tag_id`). The retired per-family params — `socials`, `chain_filters`, `app_filters`, `token_filters`, `label_filters`, `profile_filters`, `lifecycle_filter` — are rejected with a `400` if passed through `--params`.
|
|
444
|
+
|
|
437
445
|
---
|
|
438
446
|
|
|
439
447
|
## `formo import`
|
|
@@ -478,36 +486,60 @@ formo events ingest --events '[{"type":"track","event":"First"},{"type":"track",
|
|
|
478
486
|
|
|
479
487
|
## FilterCondition reference
|
|
480
488
|
|
|
481
|
-
`profiles search --
|
|
489
|
+
`profiles search --filters` accepts a JSON array of canonical filter objects:
|
|
482
490
|
|
|
483
491
|
```json
|
|
484
492
|
[
|
|
485
493
|
{ "field": "users.net_worth_usd", "op": "gt", "value": 10000 },
|
|
486
|
-
{ "field": "chains.
|
|
494
|
+
{ "field": "chains.balance", "op": "gte", "value": 1000, "chain_id": "1" }
|
|
487
495
|
]
|
|
488
496
|
```
|
|
489
497
|
|
|
490
|
-
> **The `field` must be a
|
|
498
|
+
> **The `field` must be a canonical path.** A bare name like `net_worth_usd` is
|
|
491
499
|
> silently ignored by the API (no error, no filtering — the search returns
|
|
492
|
-
> everything).
|
|
500
|
+
> everything). Resource identity goes in the named qualifiers below, never in
|
|
501
|
+
> the field path — identifier-in-path fields such as `chains.1.balance` are
|
|
502
|
+
> rejected with a `400`.
|
|
493
503
|
|
|
494
504
|
| Field | Type | Description |
|
|
495
505
|
|---|---|---|
|
|
496
|
-
| `field` | `string` |
|
|
497
|
-
| `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`
|
|
498
|
-
| `value` | `any` | Value to compare against |
|
|
499
|
-
| `
|
|
500
|
-
| `
|
|
506
|
+
| `field` | `string` | `users.{attribute}` or one of the four resource paths (see below) |
|
|
507
|
+
| `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `startsWith`, `endsWith`, `notEmpty` / `isEmpty` (value-less existence checks). Support is per field — see [operator support](#operator-support). Long-form spellings (`equals`, `greater`, `includes`, …) are retired — the API rejects them with a `400` naming the token |
|
|
508
|
+
| `value` | `any` | Value to compare against; must be a number on the `.balance` fields |
|
|
509
|
+
| `chain_id` | `string` | _(optional on any resource filter)_ restrict to one chain; omit to match any |
|
|
510
|
+
| `app_id` | `string` | _(required by `apps.balance`, and by `tokens.balance` with `scope: protocol`)_ e.g. `aave-v3` |
|
|
511
|
+
| `token_address` | `string` | _(required by `tokens.balance`)_ |
|
|
512
|
+
| `tag_id` | `string` | _(required by `labels.value`)_ e.g. `coinbase.verified_account` |
|
|
513
|
+
| `scope` | `string` | _(required by `tokens.balance`)_ `any` or `protocol` |
|
|
514
|
+
|
|
515
|
+
| Field | Required qualifiers | Example |
|
|
516
|
+
|---|---|---|
|
|
517
|
+
| `users.{attribute}` | none | `{"field":"users.net_worth_usd","op":"gt","value":10000}` |
|
|
518
|
+
| `chains.balance` | none (`chain_id` optional) | `{"field":"chains.balance","op":"gte","value":1000,"chain_id":"1"}` |
|
|
519
|
+
| `apps.balance` | `app_id` | `{"field":"apps.balance","op":"gt","value":500,"app_id":"uniswap-v3"}` |
|
|
520
|
+
| `tokens.balance` | `token_address`, `scope` | `{"field":"tokens.balance","op":"gt","value":0,"token_address":"0xA0b8…48","scope":"any"}` |
|
|
521
|
+
| `labels.value` | `tag_id` | `{"field":"labels.value","op":"eq","value":"true","tag_id":"coinbase.verified_account"}` |
|
|
501
522
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
| `tokens.` | `tokens.0xA0b8…48.balance` |
|
|
508
|
-
| `labels.` | `labels.coinbase.verified_account` |
|
|
523
|
+
User attributes for `users.{attribute}`: `net_worth_usd`, `volume`, `revenue`,
|
|
524
|
+
`points`, `device`, `location`, `lifecycle`, `ens`, `farcaster`, and the other
|
|
525
|
+
social handles.
|
|
526
|
+
|
|
527
|
+
### Operator support
|
|
509
528
|
|
|
510
|
-
|
|
529
|
+
The canonical vocabulary is shared, but each field implements a subset. The API
|
|
530
|
+
rejects an unsupported pairing with a `400`:
|
|
531
|
+
|
|
532
|
+
| Field class | Supported operators |
|
|
533
|
+
|---|---|
|
|
534
|
+
| `chains.balance`, `apps.balance`, `tokens.balance` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` (value must be a JSON number) |
|
|
535
|
+
| `labels.value` | comparison operators plus `contains` (case-insensitive) |
|
|
536
|
+
| Numeric profile metrics (`users.net_worth_usd`, `users.volume`, `users.revenue`, `users.points`) | comparison operators |
|
|
537
|
+
| Routable string attributes (`users.device`, `users.os`, `users.referrer`, `users.utm_*`, `users.click_id`, and the `first_*`/`last_*` attribution variants) | full vocabulary; `contains`/`startsWith`/`endsWith` match case-sensitively |
|
|
538
|
+
| Social fields (`users.twitter`, `users.email`, `users.farcaster`, …) | `contains` (case-insensitive) and `notEmpty`; `startsWith`/`endsWith`/`isEmpty` are rejected |
|
|
539
|
+
| `users.paid_source` (and `first_`/`last_` variants) | `eq`, `neq`, `in`, `nin`, `notEmpty`, `isEmpty` — it is a fixed ad-network enum |
|
|
540
|
+
| `users.lifecycle` | `eq` (one stage) and `in` (a list of stages) |
|
|
541
|
+
|
|
542
|
+
Combine multiple filters with `--logic and` (default) or `--logic or`.
|
|
511
543
|
|
|
512
544
|
---
|
|
513
545
|
|
package/dist/commands/alerts.js
CHANGED
|
@@ -12,6 +12,7 @@ exports.buildTestAlertBody = buildTestAlertBody;
|
|
|
12
12
|
exports.testAlertRun = testAlertRun;
|
|
13
13
|
const incur_1 = require("incur");
|
|
14
14
|
const client_1 = require("../lib/client");
|
|
15
|
+
const filters_1 = require("../lib/filters");
|
|
15
16
|
const json_1 = require("../lib/json");
|
|
16
17
|
const pagination_1 = require("../lib/pagination");
|
|
17
18
|
exports.alerts = incur_1.Cli.create('alerts', {
|
|
@@ -76,7 +77,27 @@ function buildAlertBody(options) {
|
|
|
76
77
|
trigger_filters: [],
|
|
77
78
|
};
|
|
78
79
|
if (options.triggerFilters) {
|
|
79
|
-
|
|
80
|
+
const triggerFilters = (0, json_1.parseJsonArray)(options.triggerFilters, '--trigger-filters');
|
|
81
|
+
for (const filter of triggerFilters) {
|
|
82
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
|
|
83
|
+
throw new Error('--trigger-filters must be a JSON array of {field, op, value} objects');
|
|
84
|
+
}
|
|
85
|
+
const record = filter;
|
|
86
|
+
if (typeof record.field !== 'string' ||
|
|
87
|
+
record.field.length === 0 ||
|
|
88
|
+
typeof record.value !== 'string') {
|
|
89
|
+
throw new Error('--trigger-filters: each entry requires string "field" and "value"');
|
|
90
|
+
}
|
|
91
|
+
if (record.op !== undefined &&
|
|
92
|
+
record.op !== '' &&
|
|
93
|
+
!(0, filters_1.isCanonicalFilterOperator)(record.op)) {
|
|
94
|
+
throw new Error('--trigger-filters: "op" must use the canonical filter vocabulary');
|
|
95
|
+
}
|
|
96
|
+
if (Object.keys(record).some((key) => !['field', 'op', 'value', 'numericThreshold'].includes(key))) {
|
|
97
|
+
throw new Error('--trigger-filters entries may only contain field, op, value, and numericThreshold');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
body.trigger_filters = triggerFilters;
|
|
80
101
|
}
|
|
81
102
|
if (options.recipient) {
|
|
82
103
|
body.recipient = (0, json_1.parseJsonArray)(options.recipient, '--recipient');
|
|
@@ -5,6 +5,7 @@ exports.buildAnalyticsParams = buildAnalyticsParams;
|
|
|
5
5
|
exports.runAnalytics = runAnalytics;
|
|
6
6
|
const incur_1 = require("incur");
|
|
7
7
|
const client_1 = require("../lib/client");
|
|
8
|
+
const filters_1 = require("../lib/filters");
|
|
8
9
|
const json_1 = require("../lib/json");
|
|
9
10
|
exports.analytics = incur_1.Cli.create('analytics', {
|
|
10
11
|
description: 'Pre-built analytics query commands — KPIs, funnels, retention, revenue, and top-N breakdowns',
|
|
@@ -44,6 +45,52 @@ const RESERVED_PARAM_KEYS = new Set([
|
|
|
44
45
|
'dateTo',
|
|
45
46
|
'filters',
|
|
46
47
|
]);
|
|
48
|
+
const ANALYTICS_FILTER_KEYS = new Set(['field', 'op', 'value', 'filters']);
|
|
49
|
+
const ANALYTICS_NESTED_FILTER_KEYS = new Set(['field', 'op', 'value']);
|
|
50
|
+
function validateAnalyticsFilter(filter, path, allowNested) {
|
|
51
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
|
|
52
|
+
throw new Error(`${path} must be a {field, op, value} object`);
|
|
53
|
+
}
|
|
54
|
+
const record = filter;
|
|
55
|
+
if (!allowNested && record.filters !== undefined) {
|
|
56
|
+
throw new Error(`${path}.filters must be a one-level array of leaf filters`);
|
|
57
|
+
}
|
|
58
|
+
const allowedKeys = allowNested
|
|
59
|
+
? ANALYTICS_FILTER_KEYS
|
|
60
|
+
: ANALYTICS_NESTED_FILTER_KEYS;
|
|
61
|
+
if (Object.keys(record).some((key) => !allowedKeys.has(key))) {
|
|
62
|
+
throw new Error(`${path} may only contain field, op, value${allowNested ? ', and filters' : ''}`);
|
|
63
|
+
}
|
|
64
|
+
if (typeof record.field !== 'string' || record.field.length === 0) {
|
|
65
|
+
throw new Error(`${path} requires a non-empty string "field"`);
|
|
66
|
+
}
|
|
67
|
+
if (!(0, filters_1.isCanonicalFilterOperator)(record.op)) {
|
|
68
|
+
throw new Error(`${path} requires a canonical "op"`);
|
|
69
|
+
}
|
|
70
|
+
if ((0, filters_1.isEmptyMembershipArray)(record.value)) {
|
|
71
|
+
throw new Error(`${path}: membership arrays cannot be empty`);
|
|
72
|
+
}
|
|
73
|
+
if (!(0, filters_1.isValuelessFilterOperator)(record.op) &&
|
|
74
|
+
(record.value === undefined ||
|
|
75
|
+
record.value === null ||
|
|
76
|
+
!(0, filters_1.isCanonicalFilterValue)(record.value))) {
|
|
77
|
+
throw new Error(`${path}: "value" is required for every operator except notEmpty/isEmpty`);
|
|
78
|
+
}
|
|
79
|
+
if (record.value !== undefined &&
|
|
80
|
+
record.value !== null &&
|
|
81
|
+
!(0, filters_1.isCanonicalFilterValue)(record.value)) {
|
|
82
|
+
throw new Error(`${path}: "value" must be a string, number, boolean, or string/number array`);
|
|
83
|
+
}
|
|
84
|
+
if ((0, filters_1.hasTinybirdMembershipDelimiter)(record.value)) {
|
|
85
|
+
throw new Error(`${path}: array string members cannot contain "|" because it is the Tinybird membership separator`);
|
|
86
|
+
}
|
|
87
|
+
if (record.filters !== undefined) {
|
|
88
|
+
if (!allowNested || !Array.isArray(record.filters)) {
|
|
89
|
+
throw new Error(`${path}.filters must be a one-level array of leaf filters`);
|
|
90
|
+
}
|
|
91
|
+
record.filters.forEach((nested, index) => validateAnalyticsFilter(nested, `${path}.filters[${index}]`, false));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
47
94
|
/**
|
|
48
95
|
* Build the query-string params for an analytics pipe request.
|
|
49
96
|
*
|
|
@@ -93,6 +140,7 @@ function buildAnalyticsParams(options) {
|
|
|
93
140
|
if (!Array.isArray(parsed)) {
|
|
94
141
|
throw new Error('--filters must be a valid JSON array of {field,op,value} objects');
|
|
95
142
|
}
|
|
143
|
+
parsed.forEach((filter, index) => validateAnalyticsFilter(filter, `--filters[${index}]`, true));
|
|
96
144
|
out.filters = JSON.stringify(parsed);
|
|
97
145
|
}
|
|
98
146
|
return out;
|
|
@@ -115,7 +163,7 @@ const sharedOptions = incur_1.z.object({
|
|
|
115
163
|
.string()
|
|
116
164
|
.optional()
|
|
117
165
|
.describe('JSON array of filter conditions: [{"field","op","value"}]. ' +
|
|
118
|
-
'Use op "in"/"nin" with
|
|
166
|
+
'Use op "in"/"nin" with an array value (e.g. ["chrome","firefox"]); pipe-delimited strings are also accepted. Array string members cannot contain "|".'),
|
|
119
167
|
params: incur_1.z
|
|
120
168
|
.string()
|
|
121
169
|
.optional()
|
package/dist/commands/charts.js
CHANGED
|
@@ -102,11 +102,11 @@ const chartBodyOptions = incur_1.z.object({
|
|
|
102
102
|
steps: incur_1.z
|
|
103
103
|
.string()
|
|
104
104
|
.optional()
|
|
105
|
-
.describe('JSON array of funnel
|
|
105
|
+
.describe('JSON array of funnel step objects'),
|
|
106
106
|
settings: incur_1.z
|
|
107
107
|
.string()
|
|
108
108
|
.optional()
|
|
109
|
-
.describe('JSON object of type-specific chart settings'),
|
|
109
|
+
.describe('JSON object of type-specific chart settings (for example, user_paths anchors/maxSteps/nodesPerStep or retention entryFilter/retentionFilter)'),
|
|
110
110
|
});
|
|
111
111
|
// ── List charts for a board ──
|
|
112
112
|
function listChartsRun(boardId, options = {}) {
|
|
@@ -21,16 +21,16 @@ export interface SearchProfilesOptions extends LifecycleThresholdOptions {
|
|
|
21
21
|
orderBy?: string;
|
|
22
22
|
orderDir?: string;
|
|
23
23
|
expand?: string;
|
|
24
|
-
|
|
24
|
+
filters?: string;
|
|
25
25
|
logic?: 'and' | 'or';
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
|
-
* Parse and validate the --
|
|
29
|
-
* `{ field, op, value }` objects
|
|
30
|
-
* `users.
|
|
31
|
-
*
|
|
28
|
+
* Parse and validate the --filters JSON. Ensures it is an array of
|
|
29
|
+
* `{ field, op, value }` objects carrying a canonical `field` — either
|
|
30
|
+
* `users.{attribute}` or one of the four stable resource paths, with resource
|
|
31
|
+
* identity in named qualifier properties. Exported for unit testing.
|
|
32
32
|
*/
|
|
33
|
-
export declare function
|
|
33
|
+
export declare function parseSearchFilters(raw: string): unknown[];
|
|
34
34
|
export declare function searchProfilesRun(options: SearchProfilesOptions): Promise<unknown>;
|
|
35
35
|
export interface UpdateProfileOptions {
|
|
36
36
|
properties: string;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.profilesLabels = exports.profilesProperties = exports.profiles = void 0;
|
|
4
4
|
exports.getProfileRun = getProfileRun;
|
|
5
|
-
exports.
|
|
5
|
+
exports.parseSearchFilters = parseSearchFilters;
|
|
6
6
|
exports.searchProfilesRun = searchProfilesRun;
|
|
7
7
|
exports.buildUpdateProfileBody = buildUpdateProfileBody;
|
|
8
8
|
exports.updateProfileRun = updateProfileRun;
|
|
@@ -16,6 +16,7 @@ exports.buildDeleteLabelBody = buildDeleteLabelBody;
|
|
|
16
16
|
exports.deleteProfileLabelRun = deleteProfileLabelRun;
|
|
17
17
|
const incur_1 = require("incur");
|
|
18
18
|
const client_1 = require("../lib/client");
|
|
19
|
+
const filters_1 = require("../lib/filters");
|
|
19
20
|
const json_1 = require("../lib/json");
|
|
20
21
|
exports.profiles = incur_1.Cli.create('profiles', {
|
|
21
22
|
description: 'Wallet profile commands',
|
|
@@ -111,13 +112,23 @@ exports.profiles.command('get', {
|
|
|
111
112
|
return getProfileRun(args.address, options);
|
|
112
113
|
},
|
|
113
114
|
});
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
// Prefixes that may lead a user-surface `field` (e.g. `users.net_worth_usd`).
|
|
116
|
+
// A bare name like `net_worth_usd` is silently ignored server-side (no error,
|
|
117
|
+
// no filtering — the search returns everything), so we reject it client-side.
|
|
118
|
+
const USER_FIELD_PREFIXES = new Set(['user', 'users']);
|
|
119
|
+
// The four canonical resource filter fields. Resource identity lives in named
|
|
120
|
+
// qualifier properties — never in the field path. The retired
|
|
121
|
+
// identifier-in-path spellings (`chains.1.balance`, `apps.uniswap-v3.balance`,
|
|
122
|
+
// `tokens.0x….balance`, `labels.vip`) are rejected by the API with a 400.
|
|
123
|
+
const RESOURCE_FILTER_FIELDS = new Set([
|
|
124
|
+
'chains.balance',
|
|
125
|
+
'apps.balance',
|
|
126
|
+
'tokens.balance',
|
|
127
|
+
'labels.value',
|
|
128
|
+
]);
|
|
129
|
+
// Prefixes owned by the resource fields above. A field that leads with one of
|
|
130
|
+
// these but is not an exact canonical path is a retired dynamic path.
|
|
131
|
+
const RESOURCE_FIELD_PREFIXES = new Set([
|
|
121
132
|
'chain',
|
|
122
133
|
'chains',
|
|
123
134
|
'app',
|
|
@@ -127,35 +138,138 @@ const CONDITION_FIELD_PREFIXES = new Set([
|
|
|
127
138
|
'label',
|
|
128
139
|
'labels',
|
|
129
140
|
]);
|
|
141
|
+
const QUALIFIER_KEYS = [
|
|
142
|
+
'chain_id',
|
|
143
|
+
'app_id',
|
|
144
|
+
'token_address',
|
|
145
|
+
'tag_id',
|
|
146
|
+
'scope',
|
|
147
|
+
];
|
|
148
|
+
const FILTER_ENTRY_KEYS = new Set([
|
|
149
|
+
'field',
|
|
150
|
+
'op',
|
|
151
|
+
'value',
|
|
152
|
+
...QUALIFIER_KEYS,
|
|
153
|
+
]);
|
|
130
154
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* dropped by the API, so it is rejected here. Exported for unit testing.
|
|
155
|
+
* Enforce the per-field qualifier rules, mirroring the API's schema. Sending a
|
|
156
|
+
* qualifier the field does not accept — or omitting a required one — is a 400,
|
|
157
|
+
* so we fail here with a message that names the offending key.
|
|
135
158
|
*/
|
|
136
|
-
function
|
|
159
|
+
function validateQualifiers(record, field) {
|
|
160
|
+
const present = (key) => record[key] !== undefined;
|
|
161
|
+
const required = (key) => {
|
|
162
|
+
if (!present(key)) {
|
|
163
|
+
throw new Error(`--filters: "${key}" is required for "${field}"`);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const forbidden = (keys) => {
|
|
167
|
+
for (const key of keys) {
|
|
168
|
+
if (present(key)) {
|
|
169
|
+
throw new Error(`--filters: "${key}" is not valid for "${field}"`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
switch (field) {
|
|
174
|
+
case 'chains.balance':
|
|
175
|
+
// chain_id optional — omit it to match any chain.
|
|
176
|
+
forbidden(['app_id', 'token_address', 'tag_id', 'scope']);
|
|
177
|
+
break;
|
|
178
|
+
case 'apps.balance':
|
|
179
|
+
required('app_id');
|
|
180
|
+
forbidden(['token_address', 'tag_id', 'scope']);
|
|
181
|
+
break;
|
|
182
|
+
case 'tokens.balance':
|
|
183
|
+
required('token_address');
|
|
184
|
+
required('scope');
|
|
185
|
+
if (record.scope !== 'any' && record.scope !== 'protocol') {
|
|
186
|
+
throw new Error(`--filters: "scope" must be "any" or "protocol"`);
|
|
187
|
+
}
|
|
188
|
+
// app_id identifies the protocol, so it is required by (and only by)
|
|
189
|
+
// scope: "protocol".
|
|
190
|
+
if (record.scope === 'protocol') {
|
|
191
|
+
required('app_id');
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
forbidden(['app_id']);
|
|
195
|
+
}
|
|
196
|
+
forbidden(['tag_id']);
|
|
197
|
+
break;
|
|
198
|
+
case 'labels.value':
|
|
199
|
+
required('tag_id');
|
|
200
|
+
forbidden(['app_id', 'token_address', 'scope']);
|
|
201
|
+
break;
|
|
202
|
+
default:
|
|
203
|
+
// users.* — a user attribute carries no resource identity.
|
|
204
|
+
forbidden(QUALIFIER_KEYS);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Parse and validate the --filters JSON. Ensures it is an array of
|
|
209
|
+
* `{ field, op, value }` objects carrying a canonical `field` — either
|
|
210
|
+
* `users.{attribute}` or one of the four stable resource paths, with resource
|
|
211
|
+
* identity in named qualifier properties. Exported for unit testing.
|
|
212
|
+
*/
|
|
213
|
+
function parseSearchFilters(raw) {
|
|
137
214
|
let parsed;
|
|
138
215
|
try {
|
|
139
216
|
parsed = JSON.parse(raw);
|
|
140
217
|
}
|
|
141
218
|
catch {
|
|
142
|
-
throw new Error('--
|
|
219
|
+
throw new Error('--filters must be a valid JSON array of FilterCondition objects');
|
|
143
220
|
}
|
|
144
221
|
if (!Array.isArray(parsed)) {
|
|
145
|
-
throw new Error('--
|
|
222
|
+
throw new Error('--filters must be a valid JSON array of FilterCondition objects');
|
|
146
223
|
}
|
|
147
|
-
for (const
|
|
148
|
-
if (!
|
|
149
|
-
throw new Error('--
|
|
224
|
+
for (const filter of parsed) {
|
|
225
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
|
|
226
|
+
throw new Error('--filters: each entry must be an object with field, op, value');
|
|
150
227
|
}
|
|
151
|
-
const
|
|
228
|
+
const record = filter;
|
|
229
|
+
const field = record.field;
|
|
152
230
|
if (typeof field !== 'string' || field.length === 0) {
|
|
153
|
-
throw new Error('--
|
|
231
|
+
throw new Error('--filters: each entry must have a non-empty string "field"');
|
|
232
|
+
}
|
|
233
|
+
for (const key of Object.keys(record)) {
|
|
234
|
+
if (!FILTER_ENTRY_KEYS.has(key)) {
|
|
235
|
+
// `appId` was the pre-P-2387 spelling; the API now rejects unknown keys.
|
|
236
|
+
const hint = key === 'appId' ? ' — use the snake_case "app_id" qualifier' : '';
|
|
237
|
+
throw new Error(`--filters: unknown property "${key}"${hint}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const prefix = field.split('.')[0];
|
|
241
|
+
if (!RESOURCE_FILTER_FIELDS.has(field)) {
|
|
242
|
+
if (RESOURCE_FIELD_PREFIXES.has(prefix)) {
|
|
243
|
+
throw new Error(`--filters: field "${field}" is a retired identifier-in-path spelling. ` +
|
|
244
|
+
'Use a stable path — chains.balance, apps.balance, tokens.balance, or labels.value — ' +
|
|
245
|
+
'and move the identifier into a qualifier (chain_id, app_id, token_address, tag_id). ' +
|
|
246
|
+
'The API rejects the old form with a 400.');
|
|
247
|
+
}
|
|
248
|
+
if (!field.includes('.') || !USER_FIELD_PREFIXES.has(prefix)) {
|
|
249
|
+
throw new Error(`--filters: field "${field}" must be a canonical path — either ` +
|
|
250
|
+
'users.{attribute}, or one of chains.balance, apps.balance, tokens.balance, labels.value ' +
|
|
251
|
+
'(a bare name is silently ignored by the API and returns the entire unfiltered dataset)');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
validateQualifiers(record, field);
|
|
255
|
+
// The balance fields compare numerically; a stringified number is a 400.
|
|
256
|
+
if (field !== 'labels.value' &&
|
|
257
|
+
RESOURCE_FILTER_FIELDS.has(field) &&
|
|
258
|
+
typeof record.value !== 'number') {
|
|
259
|
+
throw new Error(`--filters: "value" must be a number for "${field}"`);
|
|
260
|
+
}
|
|
261
|
+
if (field === 'labels.value' && record.value === '') {
|
|
262
|
+
throw new Error(`--filters: "value" must be non-empty for "labels.value"`);
|
|
263
|
+
}
|
|
264
|
+
if (!(0, filters_1.isCanonicalFilterOperator)(record.op)) {
|
|
265
|
+
throw new Error('--filters: each entry must use a canonical "op" (eq, neq, gt, lt, gte, lte, in, nin, startsWith, endsWith, contains, notEmpty, or isEmpty)');
|
|
154
266
|
}
|
|
155
|
-
if (
|
|
156
|
-
throw new Error(
|
|
157
|
-
|
|
158
|
-
|
|
267
|
+
if ((0, filters_1.isEmptyMembershipArray)(record.value)) {
|
|
268
|
+
throw new Error('--filters: membership arrays cannot be empty');
|
|
269
|
+
}
|
|
270
|
+
if (!(0, filters_1.isValuelessFilterOperator)(record.op) &&
|
|
271
|
+
(record.value === undefined || record.value === null)) {
|
|
272
|
+
throw new Error('--filters: "value" is required for every operator except notEmpty/isEmpty');
|
|
159
273
|
}
|
|
160
274
|
}
|
|
161
275
|
return parsed;
|
|
@@ -180,14 +294,14 @@ function searchProfilesRun(options) {
|
|
|
180
294
|
params.expand = options.expand;
|
|
181
295
|
addLifecycleThresholdParams(params, options);
|
|
182
296
|
let body;
|
|
183
|
-
if (options.
|
|
297
|
+
if (options.filters) {
|
|
184
298
|
body = {
|
|
185
|
-
|
|
299
|
+
filters: parseSearchFilters(options.filters),
|
|
186
300
|
logic: options.logic ?? 'and',
|
|
187
301
|
};
|
|
188
302
|
}
|
|
189
303
|
// INTENTIONAL: the Formo search API is `GET /v0/profiles` with the
|
|
190
|
-
// `{
|
|
304
|
+
// `{ filters, logic }` filter object in the *request body* (see
|
|
191
305
|
// docs.formo.so/api/profiles/search — it has a "Request Body (Filters)"
|
|
192
306
|
// section under a GET endpoint). This GET-with-body shape is the
|
|
193
307
|
// documented, server-supported contract. Do NOT "fix" it to POST — that
|
|
@@ -229,7 +343,7 @@ exports.profiles.command('search', {
|
|
|
229
343
|
.describe('Field to sort by'),
|
|
230
344
|
orderDir: incur_1.z.enum(['asc', 'desc']).optional().describe('Sort direction'),
|
|
231
345
|
expand: incur_1.z.string().optional().describe('Comma-separated fields to expand'),
|
|
232
|
-
|
|
346
|
+
filters: incur_1.z
|
|
233
347
|
.string()
|
|
234
348
|
.optional()
|
|
235
349
|
.describe('JSON array of FilterCondition objects: [{"field","op","value"}]. ' +
|
|
@@ -237,16 +351,24 @@ exports.profiles.command('search', {
|
|
|
237
351
|
'Profile: users.net_worth_usd, users.volume, users.revenue, users.points. ' +
|
|
238
352
|
'Engagement: users.device, users.browser, users.os, users.location, users.lifecycle. ' +
|
|
239
353
|
'Socials: users.ens, users.farcaster, users.lens, etc. ' +
|
|
240
|
-
'
|
|
241
|
-
'
|
|
242
|
-
'(
|
|
243
|
-
'
|
|
244
|
-
'(
|
|
245
|
-
'
|
|
354
|
+
'Resource filters use a stable field plus named qualifiers: ' +
|
|
355
|
+
'chains.balance (+ optional "chain_id"); ' +
|
|
356
|
+
'apps.balance (+ "app_id", optional "chain_id"); ' +
|
|
357
|
+
'tokens.balance (+ "token_address", "scope":"any"|"protocol", "app_id" when scope is "protocol", optional "chain_id"); ' +
|
|
358
|
+
'labels.value (+ "tag_id", optional "chain_id"). ' +
|
|
359
|
+
'op: eq, neq, gt, gte, lt, lte, in, nin, contains, startsWith, endsWith, notEmpty, isEmpty. ' +
|
|
360
|
+
'Operator support is per field: the .balance fields take comparison operators only; ' +
|
|
361
|
+
'contains works on routable string attributes (users.device, users.os, users.referrer, users.utm_*, users.click_id — case-sensitive), ' +
|
|
362
|
+
'on social fields and on labels.value (case-insensitive); ' +
|
|
363
|
+
'startsWith/endsWith are routable string attributes only; ' +
|
|
364
|
+
'notEmpty/isEmpty are value-less existence checks on string fields; ' +
|
|
365
|
+
'users.lifecycle takes only eq and in. ' +
|
|
366
|
+
'Retired and rejected with a 400: identifier-in-path fields (chains.1.balance, apps.uniswap-v3.balance, tokens.0x….balance, labels.vip), ' +
|
|
367
|
+
'the "appId" spelling, and long-form operators (equals, notEquals, greater, greaterOrEqual, less, lessOrEqual, notIn, includes).'),
|
|
246
368
|
logic: incur_1.z
|
|
247
369
|
.enum(['and', 'or'])
|
|
248
370
|
.optional()
|
|
249
|
-
.describe('Logic operator for combining
|
|
371
|
+
.describe('Logic operator for combining filters: "and" (default) or "or"'),
|
|
250
372
|
...lifecycleThresholdOptions,
|
|
251
373
|
}),
|
|
252
374
|
examples: [
|
|
@@ -261,14 +383,14 @@ exports.profiles.command('search', {
|
|
|
261
383
|
},
|
|
262
384
|
{
|
|
263
385
|
options: {
|
|
264
|
-
|
|
386
|
+
filters: '[{"field":"users.net_worth_usd","op":"gt","value":10000}]',
|
|
265
387
|
size: 20,
|
|
266
388
|
},
|
|
267
389
|
description: 'Search profiles with net worth > $10k',
|
|
268
390
|
},
|
|
269
391
|
{
|
|
270
392
|
options: {
|
|
271
|
-
|
|
393
|
+
filters: '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]',
|
|
272
394
|
logic: 'or',
|
|
273
395
|
size: 20,
|
|
274
396
|
},
|
|
@@ -276,13 +398,20 @@ exports.profiles.command('search', {
|
|
|
276
398
|
},
|
|
277
399
|
{
|
|
278
400
|
options: {
|
|
279
|
-
|
|
401
|
+
filters: '[{"field":"chains.balance","op":"gt","value":1000,"chain_id":"1"}]',
|
|
280
402
|
size: 20,
|
|
281
403
|
},
|
|
282
404
|
description: 'Search profiles with > $1k balance on Ethereum (chain 1)',
|
|
283
405
|
},
|
|
406
|
+
{
|
|
407
|
+
options: {
|
|
408
|
+
filters: '[{"field":"labels.value","op":"eq","value":"tier-1","tag_id":"vip"}]',
|
|
409
|
+
size: 20,
|
|
410
|
+
},
|
|
411
|
+
description: 'Search profiles carrying the vip label with value tier-1',
|
|
412
|
+
},
|
|
284
413
|
],
|
|
285
|
-
hint: 'Requires profiles:read scope on your API key. Filter "field" must be a
|
|
414
|
+
hint: 'Requires profiles:read scope on your API key. Filter "field" must be a canonical path (users.{attribute}, chains.balance, apps.balance, tokens.balance, labels.value) with resource identity in the chain_id/app_id/token_address/tag_id qualifiers — bare names are ignored by the API and identifier-in-path fields are rejected with a 400.',
|
|
286
415
|
run({ options }) {
|
|
287
416
|
return searchProfilesRun(options);
|
|
288
417
|
},
|
|
@@ -5,11 +5,11 @@ export declare const segments: Cli.Cli<{}, undefined, undefined>;
|
|
|
5
5
|
export declare function listSegmentsRun(options?: PaginationOptions): Promise<unknown>;
|
|
6
6
|
export interface CreateSegmentOptions {
|
|
7
7
|
title: string;
|
|
8
|
-
|
|
8
|
+
filters: string;
|
|
9
9
|
}
|
|
10
10
|
export declare function buildCreateSegmentBody(options: CreateSegmentOptions): {
|
|
11
11
|
title: string;
|
|
12
|
-
|
|
12
|
+
filters: unknown[];
|
|
13
13
|
};
|
|
14
14
|
export declare function createSegmentRun(options: CreateSegmentOptions): Promise<unknown>;
|
|
15
15
|
export declare function deleteSegmentRun(segmentId: string): Promise<unknown>;
|
|
@@ -7,6 +7,7 @@ exports.createSegmentRun = createSegmentRun;
|
|
|
7
7
|
exports.deleteSegmentRun = deleteSegmentRun;
|
|
8
8
|
const incur_1 = require("incur");
|
|
9
9
|
const client_1 = require("../lib/client");
|
|
10
|
+
const filters_1 = require("../lib/filters");
|
|
10
11
|
const json_1 = require("../lib/json");
|
|
11
12
|
const pagination_1 = require("../lib/pagination");
|
|
12
13
|
exports.segments = incur_1.Cli.create('segments', {
|
|
@@ -27,14 +28,47 @@ exports.segments.command('list', {
|
|
|
27
28
|
return listSegmentsRun(options);
|
|
28
29
|
},
|
|
29
30
|
});
|
|
31
|
+
const SEGMENT_FILTER_KEYS = new Set(['field', 'op', 'value']);
|
|
30
32
|
function buildCreateSegmentBody(options) {
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
throw new Error('--
|
|
33
|
+
const filters = (0, json_1.parseJsonArray)(options.filters, '--filters');
|
|
34
|
+
if (filters.length === 0) {
|
|
35
|
+
throw new Error('--filters must contain at least one filter');
|
|
36
|
+
}
|
|
37
|
+
for (const filter of filters) {
|
|
38
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
|
|
39
|
+
throw new Error('--filters must be a JSON array of {field, op, value} objects');
|
|
40
|
+
}
|
|
41
|
+
const record = filter;
|
|
42
|
+
if (Object.keys(record).some((key) => !SEGMENT_FILTER_KEYS.has(key))) {
|
|
43
|
+
throw new Error('--filters entries may only contain field, op, and value');
|
|
44
|
+
}
|
|
45
|
+
if (typeof record.field !== 'string' || record.field.length === 0) {
|
|
46
|
+
throw new Error('--filters: each entry requires a non-empty string "field"');
|
|
47
|
+
}
|
|
48
|
+
if (!(0, filters_1.isCanonicalFilterOperator)(record.op)) {
|
|
49
|
+
throw new Error('--filters: each entry requires a canonical "op"');
|
|
50
|
+
}
|
|
51
|
+
if ((0, filters_1.isEmptyMembershipArray)(record.value)) {
|
|
52
|
+
throw new Error('--filters: membership arrays cannot be empty');
|
|
53
|
+
}
|
|
54
|
+
if (!(0, filters_1.isValuelessFilterOperator)(record.op) &&
|
|
55
|
+
(record.value === undefined ||
|
|
56
|
+
record.value === null ||
|
|
57
|
+
!(0, filters_1.isCanonicalFilterValue)(record.value))) {
|
|
58
|
+
throw new Error('--filters: "value" is required for every operator except notEmpty/isEmpty');
|
|
59
|
+
}
|
|
60
|
+
if (record.value !== undefined &&
|
|
61
|
+
record.value !== null &&
|
|
62
|
+
!(0, filters_1.isCanonicalFilterValue)(record.value)) {
|
|
63
|
+
throw new Error('--filters: "value" must be a string, number, boolean, or string/number array');
|
|
64
|
+
}
|
|
65
|
+
if ((0, filters_1.hasTinybirdMembershipDelimiter)(record.value)) {
|
|
66
|
+
throw new Error('--filters: array string members cannot contain "|" because it is the Tinybird membership separator');
|
|
67
|
+
}
|
|
34
68
|
}
|
|
35
69
|
return {
|
|
36
70
|
title: options.title,
|
|
37
|
-
|
|
71
|
+
filters,
|
|
38
72
|
};
|
|
39
73
|
}
|
|
40
74
|
function createSegmentRun(options) {
|
|
@@ -46,15 +80,15 @@ exports.segments.command('create', {
|
|
|
46
80
|
description: 'Create a new user segment',
|
|
47
81
|
options: incur_1.z.object({
|
|
48
82
|
title: incur_1.z.string().describe('Segment title'),
|
|
49
|
-
|
|
83
|
+
filters: incur_1.z
|
|
50
84
|
.string()
|
|
51
|
-
.describe('JSON array of filter
|
|
85
|
+
.describe('JSON array of canonical filter objects: [{"field","op","value"}]. Array string members cannot contain "|".'),
|
|
52
86
|
}),
|
|
53
87
|
examples: [
|
|
54
88
|
{
|
|
55
89
|
options: {
|
|
56
90
|
title: 'Whales',
|
|
57
|
-
|
|
91
|
+
filters: '[{"field":"net_worth_usd","op":"gt","value":"100000"}]',
|
|
58
92
|
},
|
|
59
93
|
description: 'Create a high-value segment',
|
|
60
94
|
},
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const CANONICAL_FILTER_OPERATORS: readonly ["eq", "neq", "gt", "lt", "gte", "lte", "in", "nin", "startsWith", "endsWith", "contains", "notEmpty", "isEmpty"];
|
|
2
|
+
export declare function isCanonicalFilterOperator(op: unknown): op is string;
|
|
3
|
+
export declare function isValuelessFilterOperator(op: unknown): boolean;
|
|
4
|
+
export declare function isCanonicalFilterValue(value: unknown): boolean;
|
|
5
|
+
export declare function isEmptyMembershipArray(value: unknown): boolean;
|
|
6
|
+
export declare function hasTinybirdMembershipDelimiter(value: unknown): boolean;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CANONICAL_FILTER_OPERATORS = void 0;
|
|
4
|
+
exports.isCanonicalFilterOperator = isCanonicalFilterOperator;
|
|
5
|
+
exports.isValuelessFilterOperator = isValuelessFilterOperator;
|
|
6
|
+
exports.isCanonicalFilterValue = isCanonicalFilterValue;
|
|
7
|
+
exports.isEmptyMembershipArray = isEmptyMembershipArray;
|
|
8
|
+
exports.hasTinybirdMembershipDelimiter = hasTinybirdMembershipDelimiter;
|
|
9
|
+
exports.CANONICAL_FILTER_OPERATORS = [
|
|
10
|
+
'eq',
|
|
11
|
+
'neq',
|
|
12
|
+
'gt',
|
|
13
|
+
'lt',
|
|
14
|
+
'gte',
|
|
15
|
+
'lte',
|
|
16
|
+
'in',
|
|
17
|
+
'nin',
|
|
18
|
+
'startsWith',
|
|
19
|
+
'endsWith',
|
|
20
|
+
'contains',
|
|
21
|
+
'notEmpty',
|
|
22
|
+
'isEmpty',
|
|
23
|
+
];
|
|
24
|
+
const CANONICAL_FILTER_OPERATOR_SET = new Set(exports.CANONICAL_FILTER_OPERATORS);
|
|
25
|
+
function isCanonicalFilterOperator(op) {
|
|
26
|
+
return typeof op === 'string' && CANONICAL_FILTER_OPERATOR_SET.has(op);
|
|
27
|
+
}
|
|
28
|
+
function isValuelessFilterOperator(op) {
|
|
29
|
+
return op === 'notEmpty' || op === 'isEmpty';
|
|
30
|
+
}
|
|
31
|
+
function isCanonicalFilterValue(value) {
|
|
32
|
+
return (typeof value === 'string' ||
|
|
33
|
+
typeof value === 'number' ||
|
|
34
|
+
typeof value === 'boolean' ||
|
|
35
|
+
(Array.isArray(value) &&
|
|
36
|
+
value.length > 0 &&
|
|
37
|
+
value.every((item) => typeof item === 'string' || typeof item === 'number')));
|
|
38
|
+
}
|
|
39
|
+
function isEmptyMembershipArray(value) {
|
|
40
|
+
return Array.isArray(value) && value.length === 0;
|
|
41
|
+
}
|
|
42
|
+
function hasTinybirdMembershipDelimiter(value) {
|
|
43
|
+
return (Array.isArray(value) &&
|
|
44
|
+
value.some((item) => typeof item === 'string' && item.includes('|')));
|
|
45
|
+
}
|