Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 25 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`.

Expand Down
33 changes: 20 additions & 13 deletions SKILLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion skills/formo-analytics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<address>.balance`, or `labels.<source>.<tag>`. A bare field such as `net_worth_usd` may be ignored.
Always use canonical filter paths: `users.<attribute>` (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.<source>.<tag>` 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.

Expand Down
171 changes: 149 additions & 22 deletions src/commands/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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<string>([
'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<string, unknown>,
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
Expand All @@ -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(
Expand Down Expand Up @@ -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'])
Expand Down Expand Up @@ -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)
},
Expand Down
Loading