@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 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
- | `--conditions` | JSON array of `FilterCondition` objects (see below) |
114
- | `--logic` | Combine conditions with `and` (default) or `or` |
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 --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20
121
- formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]' --logic or --size 20
122
- formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20
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 '[{"name":"event","operator":"eq","value":"transaction"}]' \
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
- | `--filter-sets` | JSON array of filter set strings defining the segment |
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}]`. Use `in`/`nin` with a pipe-delimited value (e.g. `"chrome\|firefox"`) |
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 --conditions` accepts a JSON array of filter condition objects:
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.1.balance", "op": "gte", "value": 1000 }
494
+ { "field": "chains.balance", "op": "gte", "value": 1000, "chain_id": "1" }
487
495
  ]
488
496
  ```
489
497
 
490
- > **The `field` must be a typed path.** A bare name like `net_worth_usd` is
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). Always prefix the field with its type.
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` | Typed path (see prefixes below) |
497
- | `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains` (social fields only), `notEmpty` / `isEmpty` (value-less existence checks). Long-form spellings (`equals`, `greater`, `includes`, …) are retired — the API rejects them with a `400` naming the token |
498
- | `value` | `any` | Value to compare against |
499
- | `scope` | `string` | _(token filters only)_ `any` or `protocol` |
500
- | `appId` | `string` | _(token filters with `scope: protocol`)_ e.g. `aave-v3` |
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
- | Prefix | Examples |
503
- |---|---|
504
- | `users.` | `users.net_worth_usd`, `users.volume`, `users.revenue`, `users.points`, `users.device`, `users.location`, `users.lifecycle`, `users.ens`, `users.farcaster` |
505
- | `chains.` | `chains.balance` (any chain), `chains.1.balance` (Ethereum) |
506
- | `apps.` | `apps.uniswap-v3.balance` |
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
- Combine multiple conditions with `--logic and` (default) or `--logic or`.
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
 
@@ -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
- body.trigger_filters = (0, json_1.parseJsonArray)(options.triggerFilters, '--trigger-filters');
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 a pipe-delimited value (e.g. "chrome|firefox").'),
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()
@@ -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/user-path step objects'),
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
- conditions?: string;
24
+ filters?: string;
25
25
  logic?: 'and' | 'or';
26
26
  }
27
27
  /**
28
- * Parse and validate the --conditions JSON. Ensures it is an array of
29
- * `{ field, op, value }` objects whose `field` is a typed path (e.g.
30
- * `users.net_worth_usd`) — a bare name like `net_worth_usd` is silently
31
- * dropped by the API, so it is rejected here. Exported for unit testing.
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 parseSearchConditions(raw: string): unknown[];
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.parseSearchConditions = parseSearchConditions;
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
- // Accepted first segments for a FilterCondition `field`, mirroring the API's
115
- // parseField(). A field whose prefix is not one of these is silently ignored
116
- // server-side (no error, no filtering — the search returns everything), so we
117
- // reject it client-side with an actionable message instead.
118
- const CONDITION_FIELD_PREFIXES = new Set([
119
- 'user',
120
- 'users',
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
- * Parse and validate the --conditions JSON. Ensures it is an array of
132
- * `{ field, op, value }` objects whose `field` is a typed path (e.g.
133
- * `users.net_worth_usd`) — a bare name like `net_worth_usd` is silently
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 parseSearchConditions(raw) {
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('--conditions must be a valid JSON array of FilterCondition objects');
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('--conditions must be a valid JSON array of FilterCondition objects');
222
+ throw new Error('--filters must be a valid JSON array of FilterCondition objects');
146
223
  }
147
- for (const cond of parsed) {
148
- if (!cond || typeof cond !== 'object' || Array.isArray(cond)) {
149
- throw new Error('--conditions: each entry must be an object with field, op, value');
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 field = cond.field;
228
+ const record = filter;
229
+ const field = record.field;
152
230
  if (typeof field !== 'string' || field.length === 0) {
153
- throw new Error('--conditions: each entry must have a non-empty string "field"');
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 (!field.includes('.') || !CONDITION_FIELD_PREFIXES.has(field.split('.')[0])) {
156
- throw new Error(`--conditions: field "${field}" must be a typed path — prefix it with ` +
157
- 'users., chains., apps., tokens., or labels. ' +
158
- '(a bare name is silently ignored by the API and returns the entire unfiltered dataset)');
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.conditions) {
297
+ if (options.filters) {
184
298
  body = {
185
- conditions: parseSearchConditions(options.conditions),
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
- // `{ conditions, logic }` filter object in the *request body* (see
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
- conditions: incur_1.z
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
- 'Chains: chains.balance or chains.{chain_id}.balance. ' +
241
- 'Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance ' +
242
- '(optional "scope":"any"|"protocol" + "appId"). Labels: labels.{tag_id}. ' +
243
- 'op: eq, neq, gt, gte, lt, lte, in, nin, contains, notEmpty, isEmpty ' +
244
- '(contains = substring, social fields only; notEmpty/isEmpty = value-less existence checks on string fields). ' +
245
- 'Long-form spellings (equals, notEquals, greater, greaterOrEqual, less, lessOrEqual, notIn, includes) are retired; the API rejects them with a 400 naming the token.'),
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 conditions: "and" (default) or "or"'),
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
- conditions: '[{"field":"users.net_worth_usd","op":"gt","value":10000}]',
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
- conditions: '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]',
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
- conditions: '[{"field":"chains.1.balance","op":"gt","value":1000}]',
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 typed path (e.g. users.net_worth_usd) — bare names are ignored by the API.',
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
- filterSets: string;
8
+ filters: string;
9
9
  }
10
10
  export declare function buildCreateSegmentBody(options: CreateSegmentOptions): {
11
11
  title: string;
12
- filterSets: unknown[];
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 parsedFilterSets = (0, json_1.parseJsonArray)(options.filterSets, '--filter-sets');
32
- if (parsedFilterSets.some((item) => typeof item !== 'string')) {
33
- throw new Error('--filter-sets must be a JSON array of strings');
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
- filterSets: parsedFilterSets,
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
- filterSets: incur_1.z
83
+ filters: incur_1.z
50
84
  .string()
51
- .describe('JSON array of filter set strings defining the segment'),
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
- filterSets: '["net_worth_usd > 100000"]',
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formo/cli",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "packageManager": "pnpm@11.1.2",
5
5
  "engines": {
6
6
  "node": ">=22.12"