@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 +17 -8
- package/dist/commands/analytics.js +20 -9
- package/dist/commands/contracts.js +8 -2
- package/dist/commands/import.js +4 -0
- package/dist/commands/profiles.d.ts +2 -1
- package/dist/commands/profiles.js +45 -67
- package/dist/lib/client.d.ts +1 -1
- package/dist/lib/client.js +8 -2
- package/dist/lib/filters.d.ts +7 -0
- package/dist/lib/filters.js +70 -1
- package/package.json +7 -4
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` |
|
|
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` |
|
|
355
|
-
| `--include-in-pipeline` |
|
|
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` |
|
|
371
|
-
| `--include-in-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 }`.
|
|
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 (
|
|
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([
|
|
49
|
-
|
|
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
|
|
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
|
}
|
package/dist/commands/import.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
-
.
|
|
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 }) {
|
package/dist/lib/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/client.js
CHANGED
|
@@ -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
|
|
34
|
-
const
|
|
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)
|
package/dist/lib/filters.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/filters.js
CHANGED
|
@@ -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.
|
|
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
|
-
"
|
|
43
|
-
"
|
|
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.
|
|
58
|
+
"tsx": "^4.22.0",
|
|
56
59
|
"typescript": "^5.5.4",
|
|
57
60
|
"typescript-eslint": "^8.58.1"
|
|
58
61
|
}
|