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
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,16 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat
| `--order-by` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` |
| `--order-dir` | `asc` or `desc` |
| `--expand` | Comma-separated fields to expand |
| `--conditions` | JSON array of `FilterCondition` objects (see below) |
| `--logic` | Combine conditions with `and` (default) or `or` |
| `--filters` | JSON array of canonical `{field,op,value}` filter objects (see below) |
| `--logic` | Combine filters with `and` (default) or `or` |

```bash
formo profiles search --size 10
formo profiles search --order-by net_worth_usd --order-dir desc --size 5
formo profiles search --page 2 --size 20
formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20
formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]' --logic or --size 20
formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --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
```

### Lifecycle tuning (advanced)
Expand Down Expand Up @@ -224,7 +224,7 @@ Get a single alert by ID.

```bash
formo alerts create --name "High value tx" --trigger-type event \
--trigger-filters '[{"name":"event","operator":"eq","value":"transaction"}]' \
--trigger-filters '[{"field":"event","op":"eq","value":"transaction"}]' \
--recipient '[{"type":"email","value":["alerts@myapp.com"]}]'
```

Expand Down Expand Up @@ -389,7 +389,7 @@ List all user segments.
| Option | Description |
|---|---|
| `--title` | Segment title |
| `--filter-sets` | JSON array of filter set strings defining the segment |
| `--filters` | JSON array of canonical `{field,op,value}` filter objects |

### `segments delete <segmentId>`
Delete a user segment.
Expand Down Expand Up @@ -421,7 +421,7 @@ Pre-built analytics pipes — the same data that powers the Formo dashboard —
|---|---|
| `--date-from` | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--date-to`) |
| `--date-to` | Inclusive end date `YYYY-MM-DD` (default: today) |
| `--filters` | JSON array of `[{field,op,value}]`. Use `in`/`nin` with a pipe-delimited value (e.g. `"chrome\|firefox"`) |
| `--filters` | JSON array of `[{field,op,value}]`. For `in`/`nin`, array values are preferred; pipe-delimited strings are also accepted |
| `--params` | JSON object of pipe-specific params merged into the query (e.g. `{"limit":10,"group_by":"device"}`) |

```bash
Expand Down Expand Up @@ -478,7 +478,7 @@ formo events ingest --events '[{"type":"track","event":"First"},{"type":"track",

## FilterCondition reference

`profiles search --conditions` accepts a JSON array of filter condition objects:
`profiles search --filters` accepts a JSON array of canonical filter objects:

```json
[
Expand Down Expand Up @@ -507,7 +507,7 @@ formo events ingest --events '[{"type":"track","event":"First"},{"type":"track",
| `tokens.` | `tokens.0xA0b8…48.balance` |
| `labels.` | `labels.coinbase.verified_account` |

Combine multiple conditions with `--logic and` (default) or `--logic or`.
Combine multiple filters with `--logic and` (default) or `--logic or`.

---

Expand Down
20 changes: 10 additions & 10 deletions SKILLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ formo profiles search [options]
| `--order-by` | see below | Field to sort by |
| `--order-dir` | `asc`, `desc` | Sort direction |
| `--expand` | `string` | Comma-separated fields to expand |
| `--conditions` | JSON array | Advanced filter conditions (see below) |
| `--logic` | `and`, `or` | Logic operator for combining conditions (default `and`) |
| `--filters` | JSON array | Advanced canonical filter objects (see below) |
| `--logic` | `and`, `or` | Logic operator for combining filters (default `and`) |

**`--order-by` values:** `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points`

Expand All @@ -103,10 +103,10 @@ formo profiles search --size 10
formo profiles search --order-by net_worth_usd --order-dir desc --size 5

# Profiles with net worth over $10k
formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20
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 --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20
formo profiles search --filters '[{"field":"chains.1.balance","op":"gt","value":1000}]' --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 Down Expand Up @@ -135,7 +135,7 @@ formo profiles search --order-by tx_count --order-dir desc --page 2 --size 20 --
| `tokens.` | `tokens.0xA0b8…48.balance` |
| `labels.` | `labels.coinbase.verified_account` |

Combine multiple conditions with `--logic and` (default) or `--logic or`.
Combine multiple filters with `--logic and` (default) or `--logic or`.

---

Expand Down Expand Up @@ -203,7 +203,7 @@ formo analytics <pipe> [options]
|---|---|
| `--date-from` | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--date-to`) |
| `--date-to` | Inclusive end date `YYYY-MM-DD` (default: today) |
| `--filters` | JSON array of `[{field,op,value}]`. Use `in`/`nin` with a pipe-delimited value (e.g. `"chrome\|firefox"`) |
| `--filters` | JSON array of `[{field,op,value}]`. For `in`/`nin`, array values are preferred; pipe-delimited strings are also accepted |
| `--params` | JSON object of pipe-specific params merged into the query (e.g. `{"limit":10,"group_by":"device"}`) |

**Examples:**
Expand Down Expand Up @@ -275,7 +275,7 @@ formo alerts create --name "High value tx" --trigger-type event

# Create an alert with filters and recipients
formo alerts create --name "Whale alert" --trigger-type event \
--trigger-filters '[{"name":"revenue","operator":"gt","value":"100000"}]' \
--trigger-filters '[{"field":"revenue","op":"gt","value":"100000"}]' \
--recipient '[{"type":"webhook","value":["https://hooks.example.com/formo"]}]'
```

Expand Down Expand Up @@ -569,19 +569,19 @@ formo segments list
### Create a segment

```bash
formo segments create --title <title> --filter-sets '<json>'
formo segments create --title <title> --filters '<json>'
```

| Option | Description |
|---|---|
| `--title` | Segment title |
| `--filter-sets` | JSON array of filter set strings defining the segment |
| `--filters` | JSON array of canonical `{field,op,value}` filter objects |

> Requires `segments:write` scope.

**Examples:**
```bash
formo segments create --title "Whales" --filter-sets '["net_worth_usd > 100000"]'
formo segments create --title "Whales" --filters '[{"field":"net_worth_usd","op":"gt","value":"100000"}]'
```

### Delete a segment
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 @@ -74,7 +74,7 @@ Use a direct lookup for one address or ENS name. Use search for cohorts, orderin
formo profiles get vitalik.eth --expand labels,chains,tokens
formo profiles search --order-by net_worth_usd --order-dir desc --size 10
formo profiles search \
--conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' \
--filters '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' \
--size 20
```

Expand Down
39 changes: 38 additions & 1 deletion src/commands/alerts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Cli, z } from 'incur'
import { createClient, requireApiKey } from '../lib/client'
import { isCanonicalFilterOperator } from '../lib/filters'
import { parseJsonArray, parseJsonObject } from '../lib/json'
import {
buildPaginationParams,
Expand Down Expand Up @@ -95,10 +96,46 @@ export function buildAlertBody(options: AlertBodyOptions) {
}

if (options.triggerFilters) {
body.trigger_filters = parseJsonArray(
const triggerFilters = parseJsonArray(
options.triggerFilters,
'--trigger-filters',
)
for (const filter of triggerFilters) {
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
throw new Error(
'--trigger-filters must be a JSON array of {field, op, value} objects',
)
}
const record = filter as Record<string, unknown>
if (
typeof record.field !== 'string' ||
record.field.length === 0 ||
typeof record.value !== 'string'
) {
throw new Error(
'--trigger-filters: each entry requires string "field" and "value"',
)
}
if (
record.op !== undefined &&
record.op !== '' &&
!isCanonicalFilterOperator(record.op)
) {
throw new Error(
'--trigger-filters: "op" must use the canonical filter vocabulary',
)
}
if (
Object.keys(record).some(
(key) => !['field', 'op', 'value', 'numericThreshold'].includes(key),
)
) {
throw new Error(
'--trigger-filters entries may only contain field, op, value, and numericThreshold',
)
}
}
body.trigger_filters = triggerFilters
}

if (options.recipient) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ const sharedOptions = z.object({
.optional()
.describe(
'JSON array of filter conditions: [{"field","op","value"}]. ' +
'Use op "in"/"nin" with a pipe-delimited value (e.g. "chrome|firefox").',
'Use op "in"/"nin" with an array value (e.g. ["chrome","firefox"]); pipe-delimited strings are also accepted.',
),
params: z
.string()
Expand Down
60 changes: 39 additions & 21 deletions src/commands/profiles.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Cli, z } from 'incur'
import { createClient, requireApiKey } from '../lib/client'
import {
isCanonicalFilterOperator,
isValuelessFilterOperator,
} from '../lib/filters'
import {
parseJsonArrayOfObjects,
parseJsonObject,
Expand Down Expand Up @@ -132,15 +136,15 @@ export interface SearchProfilesOptions extends LifecycleThresholdOptions {
orderBy?: string
orderDir?: string
expand?: string
conditions?: string
filters?: string
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 CONDITION_FIELD_PREFIXES = new Set([
const FILTER_FIELD_PREFIXES = new Set([
'user',
'users',
'chain',
Expand All @@ -154,36 +158,50 @@ const CONDITION_FIELD_PREFIXES = new Set([
])

/**
* Parse and validate the --conditions JSON. Ensures it is an array of
* 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.
*/
export function parseSearchConditions(raw: string): unknown[] {
export function parseSearchFilters(raw: string): unknown[] {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new Error('--conditions must be a valid JSON array of FilterCondition objects')
throw new Error('--filters must be a valid JSON array of FilterCondition objects')
}
if (!Array.isArray(parsed)) {
throw new Error('--conditions must be a valid JSON array of FilterCondition objects')
throw new Error('--filters must be a valid JSON array of FilterCondition objects')
}
for (const cond of parsed) {
if (!cond || typeof cond !== 'object' || Array.isArray(cond)) {
throw new Error('--conditions: each entry must be an object with field, op, value')
for (const filter of parsed) {
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
throw new Error('--filters: each entry must be an object with field, op, value')
}
const field = (cond as { field?: unknown }).field
const record = filter as Record<string, unknown>
const field = record.field
if (typeof field !== 'string' || field.length === 0) {
throw new Error('--conditions: each entry must have a non-empty string "field"')
throw new Error('--filters: each entry must have a non-empty string "field"')
}
if (!field.includes('.') || !CONDITION_FIELD_PREFIXES.has(field.split('.')[0])) {
if (!field.includes('.') || !FILTER_FIELD_PREFIXES.has(field.split('.')[0])) {
throw new Error(
`--conditions: field "${field}" must be a typed path — prefix it with ` +
`--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)',
)
}
if (!isCanonicalFilterOperator(record.op)) {
throw new Error(
'--filters: each entry must use a canonical "op" (eq, neq, gt, lt, gte, lte, in, nin, startsWith, endsWith, contains, notEmpty, or isEmpty)',
)
}
if (
!isValuelessFilterOperator(record.op) &&
(record.value === undefined || record.value === null)
) {
throw new Error(
'--filters: "value" is required for every operator except notEmpty/isEmpty',
)
}
}
return parsed
}
Expand All @@ -203,15 +221,15 @@ export function searchProfilesRun(options: SearchProfilesOptions) {
addLifecycleThresholdParams(params, options)

let body: object | undefined
if (options.conditions) {
if (options.filters) {
body = {
conditions: parseSearchConditions(options.conditions),
filters: parseSearchFilters(options.filters),
logic: options.logic ?? 'and',
}
}

// INTENTIONAL: the Formo search API is `GET /v0/profiles` with the
// `{ conditions, logic }` filter object in the *request body* (see
// `{ filters, logic }` filter object in the *request body* (see
// docs.formo.so/api/profiles/search — it has a "Request Body (Filters)"
// section under a GET endpoint). This GET-with-body shape is the
// documented, server-supported contract. Do NOT "fix" it to POST — that
Expand Down Expand Up @@ -254,7 +272,7 @@ profiles.command('search', {
.describe('Field to sort by'),
orderDir: z.enum(['asc', 'desc']).optional().describe('Sort direction'),
expand: z.string().optional().describe('Comma-separated fields to expand'),
conditions: z
filters: z
.string()
.optional()
.describe(
Expand All @@ -273,7 +291,7 @@ profiles.command('search', {
logic: z
.enum(['and', 'or'])
.optional()
.describe('Logic operator for combining conditions: "and" (default) or "or"'),
.describe('Logic operator for combining filters: "and" (default) or "or"'),
...lifecycleThresholdOptions,
}),
examples: [
Expand All @@ -288,14 +306,14 @@ profiles.command('search', {
},
{
options: {
conditions: '[{"field":"users.net_worth_usd","op":"gt","value":10000}]',
filters: '[{"field":"users.net_worth_usd","op":"gt","value":10000}]',
size: 20,
},
description: 'Search profiles with net worth > $10k',
},
{
options: {
conditions:
filters:
'[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]',
logic: 'or',
size: 20,
Expand All @@ -304,7 +322,7 @@ profiles.command('search', {
},
{
options: {
conditions: '[{"field":"chains.1.balance","op":"gt","value":1000}]',
filters: '[{"field":"chains.1.balance","op":"gt","value":1000}]',
size: 20,
},
description: 'Search profiles with > $1k balance on Ethereum (chain 1)',
Expand Down
Loading