@formo/cli 1.3.0 → 1.3.2

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
@@ -148,7 +148,8 @@ Merge-update identity properties on a wallet profile.
148
148
 
149
149
  | Option | Description |
150
150
  |---|---|
151
- | `--properties` | JSON object of properties to merge |
151
+ | `--properties` | JSON object of properties to merge; use `null` to unset a property |
152
+ | `--unset` | Comma-separated property keys to unset (`user_id` cannot be unset) |
152
153
 
153
154
  **Allowed property keys:** `user_id`, `display_name`, `email`, `farcaster`, `discord`, `twitter`, `telegram`, `instagram`, `website`, `github`, `linkedin`, `facebook`, `tiktok`, `youtube`, `reddit`, `avatar`, `description`, `location`, `ens`, `lens`, `basenames`, `linea`. Unknown keys are rejected server-side.
154
155
 
@@ -157,6 +158,8 @@ formo profiles update 0xd8dA... --properties '{"display_name":"Vitalik","twitter
157
158
  formo profiles update vitalik.eth --properties '{"email":"alice@example.com"}'
158
159
  ```
159
160
 
161
+ `--unset` takes precedence over matching keys in `--properties`. Deletions also mask enriched fallback values and historical snapshot reads until a new value is set.
162
+
160
163
  > Requires `profiles:write` scope.
161
164
 
162
165
  ### `profiles properties batch`
@@ -265,7 +268,7 @@ Get a single board by ID.
265
268
  |---|---|
266
269
  | `--title` | Board title |
267
270
  | `--description` | Optional board description |
268
- | `--is-public` | Make the board publicly viewable |
271
+ | `--is-public` | Make the board publicly viewable. Omit to keep it private (the default). |
269
272
 
270
273
  ```bash
271
274
  formo boards create --title "Revenue Metrics" --description "Weekly revenue tracking"
@@ -277,7 +280,7 @@ formo boards create --title "Revenue Metrics" --description "Weekly revenue trac
277
280
  |---|---|
278
281
  | `--title` | New board title |
279
282
  | `--description` | New board description |
280
- | `--is-public` | Update public visibility |
283
+ | `--is-public` | Make the board publicly viewable. Omit to keep the stored setting; pass `--is-public=false` to make it private. |
281
284
 
282
285
  ### `boards delete <boardId>`
283
286
  Delete a board.
@@ -351,8 +354,8 @@ Get a single tracked contract.
351
354
  | `--name` | Human-readable contract name |
352
355
  | `--abi` | Contract ABI as a JSON string; sent stringified to the API |
353
356
  | `--events` | JSON array of ABI event objects to monitor |
354
- | `--start-block` | Optional start block |
355
- | `--include-in-pipeline` | Include this contract in the Goldsky events pipeline (`true` by default in the API) |
357
+ | `--start-block` | Non-negative safe integer recorded on the contract; does not backfill historical events |
358
+ | `--include-in-pipeline` | Deploy this contract to the events pipeline. Omit for decode-only (the default). |
356
359
 
357
360
  ```bash
358
361
  formo contracts create --address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984 --chain 1 \
@@ -367,8 +370,8 @@ formo contracts create --address 0x1f9840a85d5af5bf1d1762f925bdaddc4201f984 --ch
367
370
  | `--name` | Updated contract name |
368
371
  | `--abi` | Updated ABI |
369
372
  | `--events` | Updated JSON array of ABI event objects |
370
- | `--start-block` | Optional start block |
371
- | `--include-in-pipeline` | Include or exclude this contract from the Goldsky events pipeline |
373
+ | `--start-block` | Non-negative safe integer recorded on the contract; does not backfill historical events |
374
+ | `--include-in-pipeline` | Deploy this contract to the events pipeline. Omit to keep the stored setting; pass `--include-in-pipeline=false` to exclude it. |
372
375
 
373
376
 
374
377
  ### `contracts delete <chain> <address>`
@@ -435,6 +438,12 @@ formo analytics retention --filters '[{"field":"location","op":"eq","value":"US"
435
438
 
436
439
  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.
437
440
 
441
+ On user-aggregate pipes such as `lifecycle` and `frequency`, either-touch attribution filters can use a `fields` pair in place of `field`, e.g. `{"fields":["first_utm_source","last_utm_source"],"op":"eq","value":"twitter"}`.
442
+
443
+ Overview Data source filters use `field: "channel"` with `web`, `mobile`, `api`, `import`, `server`, or `onchain`; acquisition channel uses `channel_type`. User/lifecycle source filters use `source_filter` through `--params` with `field: "source"`.
444
+
445
+ Retention defaults to rolling (active in week N or later); use `--params '{"retention_type":"recurring"}'` for activity in exactly week N. Funnel steps accept `events` OR alternatives with member-level `filters`; the primary `type`/`event` is always included and step-level `filters` apply to the whole group.
446
+
438
447
  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`.
439
448
 
440
449
  ---
@@ -549,7 +558,7 @@ Every command supports the standard incur output flags:
549
558
  | `--verbose` | Include the full envelope (`ok`, `data`, `meta`) |
550
559
  | `--filter-output <keys>` | Filter output by key paths (e.g. `data,meta.duration`) |
551
560
 
552
- Every list endpoint returns a `PaginatedResponse<T>` envelope: `{ data: [...], total, page, size, has_more }`. Every error follows: `{ error: { code, message, doc_url, param?, details? } }` — branch on `error.code`, not `message`.
561
+ Every list endpoint returns a `PaginatedResponse<T>` envelope: `{ data: [...], total, page, size, has_more }`. Most API errors follow: `{ error: { code, message, doc_url, param?, details? } }` — branch on `error.code` when available. Event-ingestion errors can be `{error:"..."}`, and rate-limit responses can be plain text; HTTP status is authoritative.
553
562
 
554
563
  ---
555
564
 
@@ -17,11 +17,11 @@ exports.analytics = incur_1.Cli.create('analytics', {
17
17
  const PIPES = [
18
18
  { name: 'kpis', description: 'Traffic KPIs: visitors, pageviews, bounce rate, session duration' },
19
19
  { name: 'event_timeseries', description: 'Event counts over time' },
20
- { name: 'funnel', description: 'Conversion funnel across ordered steps. --params: steps (JSON array of {type,event,name,filters?}), window_seconds, funnel_type, group_by, limit, attribution' },
20
+ { name: 'funnel', description: 'Conversion funnel across ordered steps. --params: steps (JSON array of {type,event,name,filters?,events?}; events adds OR alternatives with member filters), window_seconds, funnel_type, group_by, limit, attribution' },
21
21
  { name: 'flow', description: 'User path/flow analysis. --params: start_step / end_step (JSON {type,event,...}), global_filters, window_seconds, max_steps' },
22
22
  { name: 'frequency', description: 'Engagement frequency distribution' },
23
- { name: 'lifecycle', description: 'User lifecycle stages (new, returning, power, resurrected, churned)' },
24
- { name: 'retention', description: 'Retention cohort analysis (params: id_type, event_type, event_name, min_users)' },
23
+ { name: 'lifecycle', description: 'User lifecycle stages (New, Returning, Power user, At Risk, Churned, Resurrected)' },
24
+ { name: 'retention', description: 'Retention cohort analysis (params: retention_type — rolling by default or recurring, id_type, event_type, event_name, min_users)' },
25
25
  { name: 'revenue_overview', description: 'Revenue overview with optional breakdown (params: group_by — incl. channel_type and paid_source for ad network, rank_by)' },
26
26
  { name: 'revenue_by_metric', description: 'Revenue ranked by a metric column (params: metric_column — incl. channel and paid_source for ad network, limit, offset)' },
27
27
  { name: 'revenue_timeseries', description: 'Revenue over time (params: address)' },
@@ -45,8 +45,11 @@ const RESERVED_PARAM_KEYS = new Set([
45
45
  'dateTo',
46
46
  'filters',
47
47
  ]);
48
- const ANALYTICS_FILTER_KEYS = new Set(['field', 'op', 'value', 'filters']);
49
- const ANALYTICS_NESTED_FILTER_KEYS = new Set(['field', 'op', 'value']);
48
+ const ANALYTICS_FILTER_KEYS = new Set([
49
+ 'field', 'fields', 'op', 'value', 'filters',
50
+ 'chain_id', 'app_id', 'token_address', 'scope', 'tag_id',
51
+ ]);
52
+ const ANALYTICS_NESTED_FILTER_KEYS = new Set(['field', 'fields', 'op', 'value']);
50
53
  function validateAnalyticsFilter(filter, path, allowNested) {
51
54
  if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
52
55
  throw new Error(`${path} must be a {field, op, value} object`);
@@ -59,11 +62,19 @@ function validateAnalyticsFilter(filter, path, allowNested) {
59
62
  ? ANALYTICS_FILTER_KEYS
60
63
  : ANALYTICS_NESTED_FILTER_KEYS;
61
64
  if (Object.keys(record).some((key) => !allowedKeys.has(key))) {
62
- throw new Error(`${path} may only contain field, op, value${allowNested ? ', and filters' : ''}`);
65
+ throw new Error(`${path} may only contain field, op, value, fields${allowNested ? ', filters, and resource qualifiers (chain_id, app_id, token_address, scope, tag_id)' : ''}`);
66
+ }
67
+ if (record.field !== undefined && record.fields !== undefined) {
68
+ throw new Error(`${path} must use only one of field or fields`);
69
+ }
70
+ if (record.fields !== undefined && (!Array.isArray(record.fields) || record.fields.length !== 2 ||
71
+ !record.fields.every((field) => typeof field === 'string' && field.length > 0))) {
72
+ throw new Error(`${path}.fields must name exactly two non-empty columns`);
63
73
  }
64
- if (typeof record.field !== 'string' || record.field.length === 0) {
65
- throw new Error(`${path} requires a non-empty string "field"`);
74
+ if (record.fields === undefined && (typeof record.field !== 'string' || record.field.length === 0)) {
75
+ throw new Error(`${path} requires a non-empty string "field" or a "fields" pair`);
66
76
  }
77
+ (0, filters_1.validateQualifiers)(record, typeof record.field === 'string' ? record.field : '', path);
67
78
  if (!(0, filters_1.isCanonicalFilterOperator)(record.op)) {
68
79
  throw new Error(`${path} requires a canonical "op"`);
69
80
  }
@@ -163,7 +174,7 @@ const sharedOptions = incur_1.z.object({
163
174
  .string()
164
175
  .optional()
165
176
  .describe('JSON array of filter conditions: [{"field","op","value"}]. ' +
166
- 'Use op "in"/"nin" with an array value (e.g. ["chrome","firefox"]); pipe-delimited strings are also accepted. Array string members cannot contain "|".'),
177
+ 'Use op "in"/"nin" with an array value (e.g. ["chrome","firefox"]); pipe-delimited strings are also accepted. Array string members cannot contain "|". Resource filters accept chain_id, app_id, token_address, scope, and tag_id qualifiers.'),
167
178
  params: incur_1.z
168
179
  .string()
169
180
  .optional()
@@ -22,6 +22,12 @@ function parseChain(chain) {
22
22
  }
23
23
  return value;
24
24
  }
25
+ function parseStartBlock(value) {
26
+ if (!Number.isSafeInteger(value) || value < 0) {
27
+ throw new Error('--start-block must be a non-negative safe integer');
28
+ }
29
+ return value;
30
+ }
25
31
  // ── List contracts ──
26
32
  function listContractsRun(options = {}) {
27
33
  (0, client_1.requireApiKey)();
@@ -76,7 +82,7 @@ function buildCreateContractBody(options) {
76
82
  events: parsedEvents,
77
83
  };
78
84
  if (options.startBlock !== undefined)
79
- body.start_block = options.startBlock;
85
+ body.start_block = parseStartBlock(options.startBlock);
80
86
  if (options.includeInPipeline !== undefined) {
81
87
  body.include_in_pipeline = options.includeInPipeline;
82
88
  }
@@ -129,7 +135,7 @@ function buildUpdateContractBody(chain, address, options) {
129
135
  events: parsedEvents,
130
136
  };
131
137
  if (options.startBlock !== undefined)
132
- body.start_block = options.startBlock;
138
+ body.start_block = parseStartBlock(options.startBlock);
133
139
  if (options.includeInPipeline !== undefined) {
134
140
  body.include_in_pipeline = options.includeInPipeline;
135
141
  }
@@ -15,6 +15,8 @@ function buildImportBody(options) {
15
15
  }
16
16
  if (options.rows) {
17
17
  const rows = (0, json_1.parseJsonArrayOfObjects)(options.rows, '--rows');
18
+ if (rows.length === 0)
19
+ throw new Error('--rows must contain at least one wallet');
18
20
  const addresses = rows.map((row) => row.address);
19
21
  if (addresses.some((address) => typeof address !== 'string' || !address)) {
20
22
  throw new Error('--rows entries must each include a non-empty string address');
@@ -28,6 +30,8 @@ function buildImportBody(options) {
28
30
  throw new Error('Provide --addresses or --rows');
29
31
  }
30
32
  const addresses = (0, json_1.parseJsonArray)(options.addresses, '--addresses');
33
+ if (addresses.length === 0)
34
+ throw new Error('--addresses must contain at least one wallet');
31
35
  if (addresses.some((address) => typeof address !== 'string' || !address)) {
32
36
  throw new Error('--addresses must be a JSON array of wallet address strings');
33
37
  }
@@ -35,7 +35,8 @@ export interface SearchProfilesOptions extends LifecycleThresholdOptions {
35
35
  export declare function parseSearchFilters(raw: string): unknown[];
36
36
  export declare function searchProfilesRun(options: SearchProfilesOptions): Promise<unknown>;
37
37
  export interface UpdateProfileOptions {
38
- properties: string;
38
+ properties?: string;
39
+ unset?: string;
39
40
  }
40
41
  export declare function buildUpdateProfileBody(options: UpdateProfileOptions): Record<string, unknown>;
41
42
  export declare function updateProfileRun(address: string, options: UpdateProfileOptions): Promise<unknown>;
@@ -150,72 +150,12 @@ const RESOURCE_FIELD_PREFIXES = new Set([
150
150
  'label',
151
151
  'labels',
152
152
  ]);
153
- const QUALIFIER_KEYS = [
154
- 'chain_id',
155
- 'app_id',
156
- 'token_address',
157
- 'tag_id',
158
- 'scope',
159
- ];
160
153
  const FILTER_ENTRY_KEYS = new Set([
161
154
  'field',
162
155
  'op',
163
156
  'value',
164
- ...QUALIFIER_KEYS,
157
+ ...filters_1.QUALIFIER_KEYS,
165
158
  ]);
166
- /**
167
- * Enforce the per-field qualifier rules, mirroring the API's schema. Sending a
168
- * qualifier the field does not accept — or omitting a required one — is a 400,
169
- * so we fail here with a message that names the offending key.
170
- */
171
- function validateQualifiers(record, field) {
172
- const present = (key) => record[key] !== undefined;
173
- const required = (key) => {
174
- if (!present(key)) {
175
- throw new Error(`--filters: "${key}" is required for "${field}"`);
176
- }
177
- };
178
- const forbidden = (keys) => {
179
- for (const key of keys) {
180
- if (present(key)) {
181
- throw new Error(`--filters: "${key}" is not valid for "${field}"`);
182
- }
183
- }
184
- };
185
- switch (field) {
186
- case 'chains.balance':
187
- // chain_id optional — omit it to match any chain.
188
- forbidden(['app_id', 'token_address', 'tag_id', 'scope']);
189
- break;
190
- case 'apps.balance':
191
- required('app_id');
192
- forbidden(['token_address', 'tag_id', 'scope']);
193
- break;
194
- case 'tokens.balance':
195
- required('token_address');
196
- required('scope');
197
- if (record.scope !== 'any' && record.scope !== 'protocol') {
198
- throw new Error(`--filters: "scope" must be "any" or "protocol"`);
199
- }
200
- // app_id identifies the protocol, so it is required by (and only by)
201
- // scope: "protocol".
202
- if (record.scope === 'protocol') {
203
- required('app_id');
204
- }
205
- else {
206
- forbidden(['app_id']);
207
- }
208
- forbidden(['tag_id']);
209
- break;
210
- case 'labels.value':
211
- required('tag_id');
212
- forbidden(['app_id', 'token_address', 'scope']);
213
- break;
214
- default:
215
- // users.* — a user attribute carries no resource identity.
216
- forbidden(QUALIFIER_KEYS);
217
- }
218
- }
219
159
  /**
220
160
  * Parse and validate the --filters JSON. Ensures it is an array of
221
161
  * `{ field, op, value }` objects carrying a canonical `field` — either
@@ -263,7 +203,7 @@ function parseSearchFilters(raw) {
263
203
  '(a bare name is silently ignored by the API and returns the entire unfiltered dataset)');
264
204
  }
265
205
  }
266
- validateQualifiers(record, field);
206
+ (0, filters_1.validateQualifiers)(record, field);
267
207
  // The balance fields compare numerically; a stringified number is a 400.
268
208
  if (field !== 'labels.value' &&
269
209
  RESOURCE_FILTER_FIELDS.has(field) &&
@@ -442,9 +382,26 @@ exports.profiles.command('search', {
442
382
  },
443
383
  });
444
384
  function buildUpdateProfileBody(options) {
445
- const body = (0, json_1.parseJsonObject)(options.properties, '--properties');
385
+ const body = options.properties
386
+ ? (0, json_1.parseJsonObject)(options.properties, '--properties')
387
+ : {};
388
+ if (options.unset) {
389
+ const keys = options.unset
390
+ .split(',')
391
+ .map((key) => key.trim())
392
+ .filter((key) => key.length > 0);
393
+ if (keys.length === 0) {
394
+ throw new Error('--unset must list at least one property key');
395
+ }
396
+ for (const key of keys) {
397
+ if (key === 'user_id') {
398
+ throw new Error('user_id cannot be unset (it participates in identity stitching)');
399
+ }
400
+ body[key] = null;
401
+ }
402
+ }
446
403
  if (Object.keys(body).length === 0) {
447
- throw new Error('--properties must contain at least one key');
404
+ throw new Error('provide --properties and/or --unset with at least one key');
448
405
  }
449
406
  return body;
450
407
  }
@@ -461,7 +418,12 @@ exports.profiles.command('update', {
461
418
  options: incur_1.z.object({
462
419
  properties: incur_1.z
463
420
  .string()
464
- .describe('JSON object of properties to merge. Allowed keys: user_id, display_name, email, farcaster, discord, twitter, telegram, instagram, website, github, linkedin, facebook, tiktok, youtube, reddit, avatar, description, location, ens, lens, basenames, linea'),
421
+ .optional()
422
+ .describe('JSON object of properties to merge; a null value unsets (deletes) that property. Allowed keys: user_id, display_name, email, farcaster, discord, twitter, telegram, instagram, website, github, linkedin, facebook, tiktok, youtube, reddit, avatar, description, location, ens, lens, basenames, linea'),
423
+ unset: incur_1.z
424
+ .string()
425
+ .optional()
426
+ .describe('Comma-separated property keys to unset (delete), e.g. "email,twitter". Shorthand for null values in --properties. user_id cannot be unset.'),
465
427
  }),
466
428
  examples: [
467
429
  {
@@ -476,8 +438,18 @@ exports.profiles.command('update', {
476
438
  options: { properties: '{"email":"alice@example.com"}' },
477
439
  description: 'Set just the email',
478
440
  },
441
+ {
442
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
443
+ options: { unset: 'email,twitter' },
444
+ description: 'Delete the email and Twitter properties',
445
+ },
446
+ {
447
+ args: { address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' },
448
+ options: { properties: '{"display_name":"alice.eth"}', unset: 'email' },
449
+ description: 'Set a new display name and delete the email in one call',
450
+ },
479
451
  ],
480
- hint: 'Requires profiles:write scope on your API key. Only the listed keys are accepted; unknown keys are rejected.',
452
+ hint: 'Requires profiles:write scope on your API key. Only the listed keys are accepted; unknown keys are rejected. Deleting a property hides any globally-enriched fallback value too; user_id cannot be unset.',
481
453
  run({ args, options }) {
482
454
  return updateProfileRun(args.address, options);
483
455
  },
@@ -507,7 +479,7 @@ exports.profilesProperties.command('batch', {
507
479
  options: incur_1.z.object({
508
480
  rows: incur_1.z
509
481
  .string()
510
- .describe('JSON array of flat {address,...properties} objects. ENS names are not resolved in batch requests.'),
482
+ .describe('JSON array of flat {address,...properties} objects; a null value unsets (deletes) that property (user_id cannot be unset). ENS names are not resolved in batch requests.'),
511
483
  }),
512
484
  examples: [
513
485
  {
@@ -516,6 +488,12 @@ exports.profilesProperties.command('batch', {
516
488
  },
517
489
  description: 'Batch set display names and emails',
518
490
  },
491
+ {
492
+ options: {
493
+ rows: '[{"address":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","email":null}]',
494
+ },
495
+ description: 'Batch delete emails (null unsets a property)',
496
+ },
519
497
  ],
520
498
  hint: 'Requires profiles:write scope on your API key. Unknown keys are ignored by the API; invalid rows are quarantined.',
521
499
  run({ options }) {
@@ -4,7 +4,7 @@ export declare const DEFAULT_EVENTS_BASE_URL = "https://events.formo.so";
4
4
  export declare function getApiBaseUrl(): string;
5
5
  export declare function getEventsBaseUrl(): string;
6
6
  export interface ApiErrorBody {
7
- error?: {
7
+ error?: string | {
8
8
  code?: string;
9
9
  message?: string;
10
10
  doc_url?: string;
@@ -30,8 +30,14 @@ function getEventsBaseUrl() {
30
30
  function parseApiError(error) {
31
31
  const status = error.response?.status;
32
32
  const body = error.response?.data;
33
- const apiError = body?.error;
34
- const baseMessage = apiError?.message ?? error.message;
33
+ const rawError = body?.error;
34
+ const apiError = rawError && typeof rawError === 'object' ? rawError : undefined;
35
+ const plainMessage = typeof rawError === 'string'
36
+ ? rawError
37
+ : typeof error.response?.data === 'string'
38
+ ? error.response.data
39
+ : undefined;
40
+ const baseMessage = apiError?.message ?? plainMessage ?? error.message;
35
41
  const parts = [];
36
42
  parts.push(apiError?.code ? `[${apiError.code}] ${baseMessage}` : baseMessage);
37
43
  if (apiError?.param)
@@ -4,3 +4,10 @@ export declare function isValuelessFilterOperator(op: unknown): boolean;
4
4
  export declare function isCanonicalFilterValue(value: unknown): boolean;
5
5
  export declare function isEmptyMembershipArray(value: unknown): boolean;
6
6
  export declare function hasTinybirdMembershipDelimiter(value: unknown): boolean;
7
+ export declare const QUALIFIER_KEYS: readonly ["chain_id", "app_id", "token_address", "tag_id", "scope"];
8
+ /**
9
+ * Enforce the per-field qualifier rules, mirroring the API's schema. Sending a
10
+ * qualifier the field does not accept — or omitting a required one — is a 400,
11
+ * so we fail here with a message that names the offending key.
12
+ */
13
+ export declare function validateQualifiers(record: Record<string, unknown>, field: string, path?: string): void;
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CANONICAL_FILTER_OPERATORS = void 0;
3
+ exports.QUALIFIER_KEYS = exports.CANONICAL_FILTER_OPERATORS = void 0;
4
4
  exports.isCanonicalFilterOperator = isCanonicalFilterOperator;
5
5
  exports.isValuelessFilterOperator = isValuelessFilterOperator;
6
6
  exports.isCanonicalFilterValue = isCanonicalFilterValue;
7
7
  exports.isEmptyMembershipArray = isEmptyMembershipArray;
8
8
  exports.hasTinybirdMembershipDelimiter = hasTinybirdMembershipDelimiter;
9
+ exports.validateQualifiers = validateQualifiers;
9
10
  exports.CANONICAL_FILTER_OPERATORS = [
10
11
  'eq',
11
12
  'neq',
@@ -43,3 +44,71 @@ function hasTinybirdMembershipDelimiter(value) {
43
44
  return (Array.isArray(value) &&
44
45
  value.some((item) => typeof item === 'string' && item.includes('|')));
45
46
  }
47
+ exports.QUALIFIER_KEYS = [
48
+ 'chain_id',
49
+ 'app_id',
50
+ 'token_address',
51
+ 'tag_id',
52
+ 'scope',
53
+ ];
54
+ /**
55
+ * Enforce the per-field qualifier rules, mirroring the API's schema. Sending a
56
+ * qualifier the field does not accept — or omitting a required one — is a 400,
57
+ * so we fail here with a message that names the offending key.
58
+ */
59
+ function validateQualifiers(record, field, path = '--filters') {
60
+ for (const key of exports.QUALIFIER_KEYS) {
61
+ if (record[key] !== undefined && (typeof record[key] !== 'string' || record[key] === '')) {
62
+ throw new Error(`${path}.${key} must be a non-empty string`);
63
+ }
64
+ }
65
+ if (record.scope !== undefined && record.scope !== 'any' && record.scope !== 'protocol') {
66
+ throw new Error(`${path}.scope must be any or protocol`);
67
+ }
68
+ const present = (key) => record[key] !== undefined;
69
+ const required = (key) => {
70
+ if (!present(key)) {
71
+ throw new Error(`${path}: "${key}" is required for "${field}"`);
72
+ }
73
+ };
74
+ const forbidden = (keys) => {
75
+ for (const key of keys) {
76
+ if (present(key)) {
77
+ throw new Error(`${path}: "${key}" is not valid for "${field}"`);
78
+ }
79
+ }
80
+ };
81
+ switch (field) {
82
+ case 'chains.balance':
83
+ // chain_id optional — omit it to match any chain.
84
+ forbidden(['app_id', 'token_address', 'tag_id', 'scope']);
85
+ break;
86
+ case 'apps.balance':
87
+ required('app_id');
88
+ forbidden(['token_address', 'tag_id', 'scope']);
89
+ break;
90
+ case 'tokens.balance':
91
+ required('token_address');
92
+ required('scope');
93
+ if (record.scope !== 'any' && record.scope !== 'protocol') {
94
+ throw new Error(`${path}: "scope" must be "any" or "protocol"`);
95
+ }
96
+ // app_id identifies the protocol, so it is required by (and only by)
97
+ // scope: "protocol".
98
+ if (record.scope === 'protocol') {
99
+ required('app_id');
100
+ }
101
+ else {
102
+ forbidden(['app_id']);
103
+ }
104
+ forbidden(['tag_id']);
105
+ break;
106
+ case 'labels.value':
107
+ required('tag_id');
108
+ forbidden(['app_id', 'token_address', 'scope']);
109
+ break;
110
+ default:
111
+ // users.* — a user attribute carries no resource identity.
112
+ forbidden(exports.QUALIFIER_KEYS);
113
+ }
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formo/cli",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "packageManager": "pnpm@11.1.2",
5
5
  "engines": {
6
6
  "node": ">=22.12"
@@ -39,8 +39,11 @@
39
39
  "test:watch": "mocha --watch"
40
40
  },
41
41
  "dependencies": {
42
- "axios": "^1.18.0",
43
- "incur": "0.4.26"
42
+ "@toon-format/toon": "^2.3.1",
43
+ "axios": "^1.20.0",
44
+ "incur": "0.4.26",
45
+ "yaml": "^2.9.1",
46
+ "zod": "^4.6.0"
44
47
  },
45
48
  "devDependencies": {
46
49
  "@eslint/js": "^10.0.1",
@@ -52,7 +55,7 @@
52
55
  "eslint": "^10.2.0",
53
56
  "globals": "^17.4.0",
54
57
  "mocha": "^11.7.5",
55
- "tsx": "^4.21.0",
58
+ "tsx": "^4.22.0",
56
59
  "typescript": "^5.5.4",
57
60
  "typescript-eslint": "^8.58.1"
58
61
  }