diff --git a/README.md b/README.md index a445751..df70929 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ formo profiles search --order-by net_worth_usd --order-dir desc --size 5 formo profiles search --page 2 --size 20 formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20 formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]' --logic or --size 20 -formo profiles search --filters '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20 +formo profiles search --filters '[{"field":"chains.balance","op":"gt","value":1000,"chain_id":"1"}]' --size 20 ``` ### Lifecycle tuning (advanced) @@ -483,29 +483,38 @@ formo events ingest --events '[{"type":"track","event":"First"},{"type":"track", ```json [ { "field": "users.net_worth_usd", "op": "gt", "value": 10000 }, - { "field": "chains.1.balance", "op": "gte", "value": 1000 } + { "field": "chains.balance", "op": "gte", "value": 1000, "chain_id": "1" } ] ``` -> **The `field` must be a typed path.** A bare name like `net_worth_usd` is +> **The `field` must be a canonical path.** A bare name like `net_worth_usd` is > silently ignored by the API (no error, no filtering — the search returns -> everything). Always prefix the field with its type. +> everything). Resource identity goes in the named qualifiers below, never in +> the field path — identifier-in-path fields such as `chains.1.balance` are +> rejected with a `400`. | Field | Type | Description | |---|---|---| -| `field` | `string` | Typed path (see prefixes below) | +| `field` | `string` | `users.{attribute}` or one of the four resource paths (see below) | | `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 | -| `value` | `any` | Value to compare against | -| `scope` | `string` | _(token filters only)_ `any` or `protocol` | -| `appId` | `string` | _(token filters with `scope: protocol`)_ e.g. `aave-v3` | - -| Prefix | Examples | -|---|---| -| `users.` | `users.net_worth_usd`, `users.volume`, `users.revenue`, `users.points`, `users.device`, `users.location`, `users.lifecycle`, `users.ens`, `users.farcaster` | -| `chains.` | `chains.balance` (any chain), `chains.1.balance` (Ethereum) | -| `apps.` | `apps.uniswap-v3.balance` | -| `tokens.` | `tokens.0xA0b8…48.balance` | -| `labels.` | `labels.coinbase.verified_account` | +| `value` | `any` | Value to compare against; must be a number on the `.balance` fields | +| `chain_id` | `string` | _(optional on any resource filter)_ restrict to one chain; omit to match any | +| `app_id` | `string` | _(required by `apps.balance`, and by `tokens.balance` with `scope: protocol`)_ e.g. `aave-v3` | +| `token_address` | `string` | _(required by `tokens.balance`)_ | +| `tag_id` | `string` | _(required by `labels.value`)_ e.g. `coinbase.verified_account` | +| `scope` | `string` | _(required by `tokens.balance`)_ `any` or `protocol` | + +| Field | Required qualifiers | Example | +|---|---|---| +| `users.{attribute}` | none | `{"field":"users.net_worth_usd","op":"gt","value":10000}` | +| `chains.balance` | none (`chain_id` optional) | `{"field":"chains.balance","op":"gte","value":1000,"chain_id":"1"}` | +| `apps.balance` | `app_id` | `{"field":"apps.balance","op":"gt","value":500,"app_id":"uniswap-v3"}` | +| `tokens.balance` | `token_address`, `scope` | `{"field":"tokens.balance","op":"gt","value":0,"token_address":"0xA0b8…48","scope":"any"}` | +| `labels.value` | `tag_id` | `{"field":"labels.value","op":"eq","value":"true","tag_id":"coinbase.verified_account"}` | + +User attributes for `users.{attribute}`: `net_worth_usd`, `volume`, `revenue`, +`points`, `device`, `location`, `lifecycle`, `ens`, `farcaster`, and the other +social handles. Combine multiple filters with `--logic and` (default) or `--logic or`. diff --git a/SKILLS.md b/SKILLS.md index 85acf52..90607cc 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -106,7 +106,7 @@ formo profiles search --order-by net_worth_usd --order-dir desc --size 5 formo profiles search --filters '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20 # Profiles with > $1k balance on Ethereum (chain 1) -formo profiles search --filters '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20 +formo profiles search --filters '[{"field":"chains.balance","op":"gt","value":1000,"chain_id":"1"}]' --size 20 # Second page of 20, sorted by tx count formo profiles search --order-by tx_count --order-dir desc --page 2 --size 20 --expand labels @@ -119,21 +119,28 @@ formo profiles search --order-by tx_count --order-dir desc --page 2 --size 20 -- | Property | Type | Description | |---|---|---| -| `field` | `string` | **Typed path** — a bare name like `net_worth_usd` is silently ignored by the API | +| `field` | `string` | **Canonical path** — `users.{attribute}` or a resource path below. A bare name like `net_worth_usd` is silently ignored by the API; identifier-in-path fields like `chains.1.balance` are rejected with a `400` | | `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 | -| `value` | `any` | Value to compare against | -| `scope` | `string` | _(token filters only)_ `any` or `protocol` | -| `appId` | `string` | _(token filters with `scope: protocol`)_ e.g. `aave-v3` | +| `value` | `any` | Value to compare against; must be a number on the `.balance` fields | +| `chain_id` | `string` | _(optional on any resource filter)_ restrict to one chain; omit to match any | +| `app_id` | `string` | _(required by `apps.balance`, and by `tokens.balance` with `scope: protocol`)_ e.g. `aave-v3` | +| `token_address` | `string` | _(required by `tokens.balance`)_ | +| `tag_id` | `string` | _(required by `labels.value`)_ e.g. `coinbase.verified_account` | +| `scope` | `string` | _(required by `tokens.balance`)_ `any` or `protocol` | -**Field path prefixes:** +**Canonical fields — resource identity goes in the qualifiers, never in the path:** -| Prefix | Examples | -|---|---| -| `users.` | `users.net_worth_usd`, `users.volume`, `users.revenue`, `users.points`, `users.device`, `users.location`, `users.lifecycle`, `users.ens`, `users.farcaster` | -| `chains.` | `chains.balance` (any chain), `chains.1.balance` (Ethereum) | -| `apps.` | `apps.uniswap-v3.balance` | -| `tokens.` | `tokens.0xA0b8…48.balance` | -| `labels.` | `labels.coinbase.verified_account` | +| Field | Required qualifiers | Example | +|---|---|---| +| `users.{attribute}` | none | `{"field":"users.net_worth_usd","op":"gt","value":10000}` | +| `chains.balance` | none (`chain_id` optional) | `{"field":"chains.balance","op":"gte","value":1000,"chain_id":"1"}` | +| `apps.balance` | `app_id` | `{"field":"apps.balance","op":"gt","value":500,"app_id":"uniswap-v3"}` | +| `tokens.balance` | `token_address`, `scope` | `{"field":"tokens.balance","op":"gt","value":0,"token_address":"0xA0b8…48","scope":"any"}` | +| `labels.value` | `tag_id` | `{"field":"labels.value","op":"eq","value":"true","tag_id":"coinbase.verified_account"}` | + +User attributes for `users.{attribute}`: `net_worth_usd`, `volume`, `revenue`, +`points`, `device`, `location`, `lifecycle`, `ens`, `farcaster`, and the other +social handles. Combine multiple filters with `--logic and` (default) or `--logic or`. diff --git a/skills/formo-analytics/SKILL.md b/skills/formo-analytics/SKILL.md index bb592e1..1399e3e 100644 --- a/skills/formo-analytics/SKILL.md +++ b/skills/formo-analytics/SKILL.md @@ -78,7 +78,7 @@ formo profiles search \ --size 20 ``` -Always use typed filter paths such as `users.net_worth_usd`, `chains.1.balance`, `apps.uniswap-v3.balance`, `tokens.
.balance`, or `labels..`. A bare field such as `net_worth_usd` may be ignored. +Always use canonical filter paths: `users.` (e.g. `users.net_worth_usd`), or one of the four resource fields — `chains.balance`, `apps.balance`, `tokens.balance`, `labels.value` — with the resource identified by a named qualifier (`chain_id`, `app_id`, `token_address`, `tag_id`) alongside `field`/`op`/`value`. A bare field such as `net_worth_usd` may be ignored, and identifier-in-path spellings such as `chains.1.balance` or `labels..` are rejected with a `400`. Profile updates, label changes, and wallet imports require `profiles:write`. Preserve pagination metadata and continue only while `has_more` is true. diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index 81b014f..7d2346a 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -141,13 +141,25 @@ export interface SearchProfilesOptions extends LifecycleThresholdOptions { logic?: 'and' | 'or' } -// Accepted first segments for a FilterCondition `field`, mirroring the API's -// parseField(). A field whose prefix is not one of these is silently ignored -// server-side (no error, no filtering — the search returns everything), so we -// reject it client-side with an actionable message instead. -const FILTER_FIELD_PREFIXES = new Set([ - 'user', - 'users', +// Prefixes that may lead a user-surface `field` (e.g. `users.net_worth_usd`). +// A bare name like `net_worth_usd` is silently ignored server-side (no error, +// no filtering — the search returns everything), so we reject it client-side. +const USER_FIELD_PREFIXES = new Set(['user', 'users']) + +// The four canonical resource filter fields. Resource identity lives in named +// qualifier properties — never in the field path. The retired +// identifier-in-path spellings (`chains.1.balance`, `apps.uniswap-v3.balance`, +// `tokens.0x….balance`, `labels.vip`) are rejected by the API with a 400. +const RESOURCE_FILTER_FIELDS = new Set([ + 'chains.balance', + 'apps.balance', + 'tokens.balance', + 'labels.value', +]) + +// Prefixes owned by the resource fields above. A field that leads with one of +// these but is not an exact canonical path is a retired dynamic path. +const RESOURCE_FIELD_PREFIXES = new Set([ 'chain', 'chains', 'app', @@ -158,11 +170,83 @@ const FILTER_FIELD_PREFIXES = new Set([ 'labels', ]) +const QUALIFIER_KEYS = [ + 'chain_id', + 'app_id', + 'token_address', + 'tag_id', + 'scope', +] as const + +const FILTER_ENTRY_KEYS = new Set([ + 'field', + 'op', + 'value', + ...QUALIFIER_KEYS, +]) + +/** + * Enforce the per-field qualifier rules, mirroring the API's schema. Sending a + * qualifier the field does not accept — or omitting a required one — is a 400, + * so we fail here with a message that names the offending key. + */ +function validateQualifiers( + record: Record, + field: string, +): void { + const present = (key: string) => record[key] !== undefined + const required = (key: string) => { + if (!present(key)) { + throw new Error(`--filters: "${key}" is required for "${field}"`) + } + } + const forbidden = (keys: readonly string[]) => { + for (const key of keys) { + if (present(key)) { + throw new Error(`--filters: "${key}" is not valid for "${field}"`) + } + } + } + + switch (field) { + case 'chains.balance': + // chain_id optional — omit it to match any chain. + forbidden(['app_id', 'token_address', 'tag_id', 'scope']) + break + case 'apps.balance': + required('app_id') + forbidden(['token_address', 'tag_id', 'scope']) + break + case 'tokens.balance': + required('token_address') + required('scope') + if (record.scope !== 'any' && record.scope !== 'protocol') { + throw new Error(`--filters: "scope" must be "any" or "protocol"`) + } + // app_id identifies the protocol, so it is required by (and only by) + // scope: "protocol". + if (record.scope === 'protocol') { + required('app_id') + } else { + forbidden(['app_id']) + } + forbidden(['tag_id']) + break + case 'labels.value': + required('tag_id') + forbidden(['app_id', 'token_address', 'scope']) + break + default: + // users.* — a user attribute carries no resource identity. + forbidden(QUALIFIER_KEYS) + } +} + /** * Parse and validate the --filters JSON. Ensures it is an array of - * `{ field, op, value }` objects whose `field` is a typed path (e.g. - * `users.net_worth_usd`) — a bare name like `net_worth_usd` is silently - * dropped by the API, so it is rejected here. Exported for unit testing. + * `{ field, op, value }` objects carrying a canonical `field` — either + * `users.{attribute}` or one of the four stable resource paths, with resource + * identity in named qualifier properties. Exported for unit testing. */ export function parseSearchFilters(raw: string): unknown[] { let parsed: unknown @@ -183,12 +267,43 @@ export function parseSearchFilters(raw: string): unknown[] { if (typeof field !== 'string' || field.length === 0) { throw new Error('--filters: each entry must have a non-empty string "field"') } - if (!field.includes('.') || !FILTER_FIELD_PREFIXES.has(field.split('.')[0])) { - throw new Error( - `--filters: field "${field}" must be a typed path — prefix it with ` + - 'users., chains., apps., tokens., or labels. ' + - '(a bare name is silently ignored by the API and returns the entire unfiltered dataset)', - ) + for (const key of Object.keys(record)) { + if (!FILTER_ENTRY_KEYS.has(key)) { + // `appId` was the pre-P-2387 spelling; the API now rejects unknown keys. + const hint = + key === 'appId' ? ' — use the snake_case "app_id" qualifier' : '' + throw new Error(`--filters: unknown property "${key}"${hint}`) + } + } + const prefix = field.split('.')[0] + if (!RESOURCE_FILTER_FIELDS.has(field)) { + if (RESOURCE_FIELD_PREFIXES.has(prefix)) { + throw new Error( + `--filters: field "${field}" is a retired identifier-in-path spelling. ` + + 'Use a stable path — chains.balance, apps.balance, tokens.balance, or labels.value — ' + + 'and move the identifier into a qualifier (chain_id, app_id, token_address, tag_id). ' + + 'The API rejects the old form with a 400.', + ) + } + if (!field.includes('.') || !USER_FIELD_PREFIXES.has(prefix)) { + throw new Error( + `--filters: field "${field}" must be a canonical path — either ` + + 'users.{attribute}, or one of chains.balance, apps.balance, tokens.balance, labels.value ' + + '(a bare name is silently ignored by the API and returns the entire unfiltered dataset)', + ) + } + } + validateQualifiers(record, field) + // The balance fields compare numerically; a stringified number is a 400. + if ( + field !== 'labels.value' && + RESOURCE_FILTER_FIELDS.has(field) && + typeof record.value !== 'number' + ) { + throw new Error(`--filters: "value" must be a number for "${field}"`) + } + if (field === 'labels.value' && record.value === '') { + throw new Error(`--filters: "value" must be non-empty for "labels.value"`) } if (!isCanonicalFilterOperator(record.op)) { throw new Error( @@ -285,12 +400,15 @@ profiles.command('search', { 'Profile: users.net_worth_usd, users.volume, users.revenue, users.points. ' + 'Engagement: users.device, users.browser, users.os, users.location, users.lifecycle. ' + 'Socials: users.ens, users.farcaster, users.lens, etc. ' + - 'Chains: chains.balance or chains.{chain_id}.balance. ' + - 'Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance ' + - '(optional "scope":"any"|"protocol" + "appId"). Labels: labels.{tag_id}. ' + + 'Resource filters use a stable field plus named qualifiers: ' + + 'chains.balance (+ optional "chain_id"); ' + + 'apps.balance (+ "app_id", optional "chain_id"); ' + + 'tokens.balance (+ "token_address", "scope":"any"|"protocol", "app_id" when scope is "protocol", optional "chain_id"); ' + + 'labels.value (+ "tag_id", optional "chain_id"). ' + 'op: eq, neq, gt, gte, lt, lte, in, nin, contains, notEmpty, isEmpty ' + '(contains = substring, social fields only; notEmpty/isEmpty = value-less existence checks on string fields). ' + - 'Long-form spellings (equals, notEquals, greater, greaterOrEqual, less, lessOrEqual, notIn, includes) are retired; the API rejects them with a 400 naming the token.', + 'Retired and rejected with a 400: identifier-in-path fields (chains.1.balance, apps.uniswap-v3.balance, tokens.0x….balance, labels.vip), ' + + 'the "appId" spelling, and long-form operators (equals, notEquals, greater, greaterOrEqual, less, lessOrEqual, notIn, includes).', ), logic: z .enum(['and', 'or']) @@ -326,13 +444,22 @@ profiles.command('search', { }, { options: { - filters: '[{"field":"chains.1.balance","op":"gt","value":1000}]', + filters: + '[{"field":"chains.balance","op":"gt","value":1000,"chain_id":"1"}]', size: 20, }, description: 'Search profiles with > $1k balance on Ethereum (chain 1)', }, + { + options: { + filters: + '[{"field":"labels.value","op":"eq","value":"tier-1","tag_id":"vip"}]', + size: 20, + }, + description: 'Search profiles carrying the vip label with value tier-1', + }, ], - 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.', + 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.', run({ options }) { return searchProfilesRun(options) }, diff --git a/test/commands/bodyBuilders.test.ts b/test/commands/bodyBuilders.test.ts index 3bc69ce..fd5de18 100644 --- a/test/commands/bodyBuilders.test.ts +++ b/test/commands/bodyBuilders.test.ts @@ -393,22 +393,93 @@ describe('commands / body builders', function () { // ── Profiles search filters ── describe('parseSearchFilters()', function () { - it('accepts filters with typed field prefixes', function () { + it('accepts filters with canonical field paths', function () { const filters = parseSearchFilters( - '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"chains.1.balance","op":"gte","value":1000}]', + '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"chains.balance","op":"gte","value":1000,"chain_id":"1"}]', ); expect(filters).to.have.length(2); expect((filters[0] as { field: string }).field).to.equal('users.net_worth_usd'); }); - it('accepts apps., tokens., and labels. prefixes', function () { + it('accepts apps., tokens., and labels. resource filters with qualifiers', function () { expect(() => parseSearchFilters( - '[{"field":"apps.uniswap-v3.balance","op":"gt","value":0},{"field":"tokens.0xabc.balance","op":"gt","value":1},{"field":"labels.coinbase.verified_account","op":"eq","value":"true"}]', + '[{"field":"apps.balance","op":"gt","value":0,"app_id":"uniswap-v3"},{"field":"tokens.balance","op":"gt","value":1,"token_address":"0xabc","scope":"any"},{"field":"labels.value","op":"eq","value":"true","tag_id":"coinbase.verified_account"}]', ), ).to.not.throw(); }); + it('accepts a protocol-scoped token filter carrying app_id', function () { + expect(() => + parseSearchFilters( + '[{"field":"tokens.balance","op":"gt","value":1,"token_address":"0xabc","scope":"protocol","app_id":"aave-v3"}]', + ), + ).to.not.throw(); + }); + + // The retired dialect: identifiers embedded in the field path. The API + // rejects these with a 400, so the CLI must not build such a request. + it('rejects retired identifier-in-path fields', function () { + for (const field of [ + 'chains.1.balance', + 'apps.uniswap-v3.balance', + 'tokens.0xabc.balance', + 'labels.coinbase.verified_account', + ]) { + expect(() => + parseSearchFilters(`[{"field":"${field}","op":"gt","value":1}]`), + ).to.throw(/retired identifier-in-path spelling/); + } + }); + + it('rejects the retired appId spelling', function () { + expect(() => + parseSearchFilters( + '[{"field":"tokens.balance","op":"gt","value":1,"token_address":"0xabc","scope":"protocol","appId":"aave-v3"}]', + ), + ).to.throw(/unknown property "appId".*app_id/); + }); + + it('requires the qualifier each resource field identifies by', function () { + expect(() => + parseSearchFilters('[{"field":"apps.balance","op":"gt","value":1}]'), + ).to.throw(/"app_id" is required/); + expect(() => + parseSearchFilters('[{"field":"labels.value","op":"eq","value":"x"}]'), + ).to.throw(/"tag_id" is required/); + expect(() => + parseSearchFilters( + '[{"field":"tokens.balance","op":"gt","value":1,"scope":"any"}]', + ), + ).to.throw(/"token_address" is required/); + expect(() => + parseSearchFilters( + '[{"field":"tokens.balance","op":"gt","value":1,"token_address":"0xabc","scope":"protocol"}]', + ), + ).to.throw(/"app_id" is required/); + }); + + it('rejects a qualifier the field does not accept', function () { + expect(() => + parseSearchFilters( + '[{"field":"chains.balance","op":"gt","value":1,"app_id":"uniswap-v3"}]', + ), + ).to.throw(/"app_id" is not valid/); + expect(() => + parseSearchFilters( + '[{"field":"users.net_worth_usd","op":"gt","value":1,"chain_id":"1"}]', + ), + ).to.throw(/"chain_id" is not valid/); + }); + + it('requires a numeric value on the balance fields', function () { + expect(() => + parseSearchFilters( + '[{"field":"chains.balance","op":"gt","value":"1000"}]', + ), + ).to.throw(/"value" must be a number/); + }); + it('rejects retired long-form operator tokens locally', function () { expect(() => parseSearchFilters( @@ -420,13 +491,13 @@ describe('commands / body builders', function () { it('rejects a bare (untyped) field — the silent-failure footgun', function () { expect(() => parseSearchFilters('[{"field":"net_worth_usd","op":"gt","value":10000}]'), - ).to.throw(/must be a typed path/); + ).to.throw(/must be a canonical path/); }); it('rejects a known field name without its prefix', function () { expect(() => parseSearchFilters('[{"field":"tx_count","op":"gt","value":5}]'), - ).to.throw(/must be a typed path/); + ).to.throw(/must be a canonical path/); }); it('throws on invalid JSON', function () {