diff --git a/README.md b/README.md index e06fa0b..7e7563b 100644 --- a/README.md +++ b/README.md @@ -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 `FilterCondition` 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) @@ -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"]}]' ``` @@ -389,7 +389,11 @@ 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 | + +```bash +formo segments create --title "Whales" --filters '[{"field":"net_worth_usd","op":"gt","value":100000}]' +``` ### `segments delete ` Delete a user segment. @@ -430,9 +434,10 @@ formo analytics kpis --date-from 2026-04-01 --date-to 2026-04-30 --params '{"gro formo analytics funnel --date-from 2026-04-01 --date-to 2026-04-30 --params '{"steps":[{"type":"event","event":"page","name":"page::0","filters":[]},{"type":"track","event":"connect","name":"connect::1","filters":[]}],"window_seconds":86400}' formo analytics top_wallets --date-from 2026-04-01 --date-to 2026-04-30 --params '{"limit":10}' formo analytics retention --filters '[{"field":"location","op":"eq","value":"US"}]' +formo analytics kpis --filters '[{"field":"page","op":"eq","value":"/pricing"}]' --params '{"page_scope":"page"}' ``` -> Requires `query:read` scope. Run `formo analytics --help` for the pipe-specific params accepted via `--params`. +`page_scope` accepts `page` (default) or `session` on KPI, revenue, and `top_*` endpoints. It only changes requests with a `page` filter. Requires `query:read` scope. Run `formo analytics --help` for pipe-specific params accepted via `--params`. --- @@ -478,7 +483,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 filter condition objects: ```json [ @@ -507,7 +512,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`. --- diff --git a/SKILLS.md b/SKILLS.md index 5d0f619..7511c71 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -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 filter conditions (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` @@ -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 @@ -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`. --- @@ -222,9 +222,12 @@ formo analytics top_wallets --date-from 2026-04-01 --date-to 2026-04-30 --params # Retention filtered to US visitors formo analytics retention --filters '[{"field":"location","op":"eq","value":"US"}]' + +# Page-scoped KPIs for the pricing page +formo analytics kpis --filters '[{"field":"page","op":"eq","value":"/pricing"}]' --params '{"page_scope":"page"}' ``` -Each pipe accepts pipe-specific params via `--params` (see each command's `--help`): e.g. `funnel` → `steps`, `window_seconds`, `funnel_type`, `group_by`, `limit`, `attribution`; `kpis` → `group_by`, `limit`; `top_*` → `limit`, `offset`. +Each pipe accepts pipe-specific params via `--params` (see each command's `--help`): e.g. `funnel` → `steps`, `window_seconds`, `funnel_type`, `group_by`, `limit`, `attribution`; `kpis` → `group_by`, `limit`; `top_*` → `limit`, `offset`. `page_scope` accepts `page` (default) or `session` on KPI, revenue, and `top_*` endpoints, and only changes requests that include a `page` filter. --- @@ -275,7 +278,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"]}]' ``` @@ -569,19 +572,19 @@ formo segments list ### Create a segment ```bash -formo segments create --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 diff --git a/skills/formo-analytics/SKILL.md b/skills/formo-analytics/SKILL.md index cf6be47..bb592e1 100644 --- a/skills/formo-analytics/SKILL.md +++ b/skills/formo-analytics/SKILL.md @@ -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 ``` diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts index cece986..b0d936f 100644 --- a/src/commands/alerts.ts +++ b/src/commands/alerts.ts @@ -75,7 +75,7 @@ const alertBodyOptionsSchema = { triggerFilters: z .string() .optional() - .describe('JSON array of trigger filter objects'), + .describe('JSON array of canonical {"field","op","value"} trigger filters'), recipient: z .string() .optional() diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts index b37fe66..3a31525 100644 --- a/src/commands/analytics.ts +++ b/src/commands/analytics.ts @@ -139,7 +139,7 @@ const sharedOptions = z.object({ .optional() .describe( 'JSON object of pipe-specific params merged into the query, e.g. ' + - '{"limit":10,"group_by":"device"} or funnel ' + + '{"limit":10,"group_by":"device"}, {"page_scope":"session"} for a page filter, or funnel ' + '{"steps":[{"type":"event","event":"page","name":"page::0","filters":[]}]}. ' + 'May not set date_from/date_to/filters; use the dedicated --date-from/--date-to/--filters flags.', ), diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index b248db0..115f107 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -132,7 +132,7 @@ export interface SearchProfilesOptions extends LifecycleThresholdOptions { orderBy?: string orderDir?: string expand?: string - conditions?: string + filters?: string logic?: 'and' | 'or' } @@ -154,32 +154,32 @@ 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') + throw new Error('--filters: each entry must be an object with field, op, value') } const field = (cond as { field?: unknown }).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])) { 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)', ) @@ -203,15 +203,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 @@ -254,7 +254,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( @@ -273,7 +273,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: [ @@ -288,14 +288,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, @@ -304,7 +304,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)', diff --git a/src/commands/segments.ts b/src/commands/segments.ts index 6c03279..2424707 100644 --- a/src/commands/segments.ts +++ b/src/commands/segments.ts @@ -35,18 +35,24 @@ segments.command('list', { export interface CreateSegmentOptions { title: string - filterSets: string + filters: string } export function buildCreateSegmentBody(options: CreateSegmentOptions) { - const parsedFilterSets = parseJsonArray(options.filterSets, '--filter-sets') - if (parsedFilterSets.some((item) => typeof item !== 'string')) { - throw new Error('--filter-sets must be a JSON array of strings') + const filters = parseJsonArray(options.filters, '--filters') + for (const filter of filters) { + if (!filter || typeof filter !== 'object' || Array.isArray(filter)) { + throw new Error('--filters must be a JSON array of {field, op, value} objects') + } + const { field, op } = filter as { field?: unknown; op?: unknown } + if (typeof field !== 'string' || field.length === 0 || typeof op !== 'string' || op.length === 0) { + throw new Error('--filters: each entry requires non-empty string "field" and "op" properties') + } } return { title: options.title, - filterSets: parsedFilterSets, + filters, } } @@ -60,15 +66,17 @@ segments.command('create', { description: 'Create a new user segment', options: z.object({ title: z.string().describe('Segment title'), - filterSets: z + filters: z .string() - .describe('JSON array of filter set strings defining the segment'), + .describe( + 'JSON array of canonical {"field","op","value"} filter objects', + ), }), examples: [ { options: { title: 'Whales', - filterSets: '["net_worth_usd > 100000"]', + filters: '[{"field":"net_worth_usd","op":"gt","value":100000}]', }, description: 'Create a high-value segment', }, diff --git a/test/commands/bodyBuilders.test.ts b/test/commands/bodyBuilders.test.ts index c798758..f0fb6ea 100644 --- a/test/commands/bodyBuilders.test.ts +++ b/test/commands/bodyBuilders.test.ts @@ -17,7 +17,7 @@ import { buildCreateLabelBody, buildDeleteLabelBody, buildUpdateProfileBody, - parseSearchConditions, + parseSearchFilters, } from '../../src/commands/profiles'; import { buildCreateSegmentBody } from '../../src/commands/segments'; import { buildIngestEventsBody } from '../../src/commands/events'; @@ -39,10 +39,10 @@ describe('commands / body builders', function () { const body = buildAlertBody({ name: 'x', triggerType: 'event', - triggerFilters: '[{"name":"event","operator":"eq","value":"transaction"}]', + triggerFilters: '[{"field":"event","op":"eq","value":"transaction"}]', }); expect(body.trigger_filters).to.deep.equal([ - { name: 'event', operator: 'eq', value: 'transaction' }, + { field: 'event', op: 'eq', value: 'transaction' }, ]); }); @@ -232,14 +232,14 @@ describe('commands / body builders', function () { // ── Segments ── describe('buildCreateSegmentBody()', function () { - it('parses filterSets JSON into the body', function () { + it('parses filters JSON into the body', function () { const body = buildCreateSegmentBody({ title: 'Whales', - filterSets: '["net_worth_usd > 100000"]', + filters: '[{"field":"net_worth_usd","op":"gt","value":100000}]', }); expect(body).to.deep.equal({ title: 'Whales', - filterSets: ['net_worth_usd > 100000'], + filters: [{ field: 'net_worth_usd', op: 'gt', value: 100000 }], }); }); }); @@ -368,11 +368,11 @@ describe('commands / body builders', function () { }); }); - // ── Profiles search conditions ── + // ── Profiles search filters ── - describe('parseSearchConditions()', function () { - it('accepts conditions with typed field prefixes', function () { - const conds = parseSearchConditions( + describe('parseSearchFilters()', function () { + it('accepts filters with typed field prefixes', function () { + const conds = parseSearchFilters( '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"chains.1.balance","op":"gte","value":1000}]', ); expect(conds).to.have.length(2); @@ -381,14 +381,14 @@ describe('commands / body builders', function () { it('accepts apps., tokens., and labels. prefixes', function () { expect(() => - parseSearchConditions( + 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"}]', ), ).to.not.throw(); }); it('passes long-form op tokens through verbatim (server rejects them with a 400 naming the token)', function () { - const conds = parseSearchConditions( + const conds = parseSearchFilters( '[{"field":"users.net_worth_usd","op":"greater","value":10000}]', ); expect((conds[0] as { op: string }).op).to.equal('greater'); @@ -396,30 +396,30 @@ describe('commands / body builders', function () { it('rejects a bare (untyped) field — the silent-failure footgun', function () { expect(() => - parseSearchConditions('[{"field":"net_worth_usd","op":"gt","value":10000}]'), + parseSearchFilters('[{"field":"net_worth_usd","op":"gt","value":10000}]'), ).to.throw(/must be a typed path/); }); it('rejects a known field name without its prefix', function () { expect(() => - parseSearchConditions('[{"field":"tx_count","op":"gt","value":5}]'), + parseSearchFilters('[{"field":"tx_count","op":"gt","value":5}]'), ).to.throw(/must be a typed path/); }); it('throws on invalid JSON', function () { - expect(() => parseSearchConditions('not-json')).to.throw( + expect(() => parseSearchFilters('not-json')).to.throw( /valid JSON array of FilterCondition/, ); }); it('throws when not an array', function () { - expect(() => parseSearchConditions('{"field":"users.net_worth_usd"}')).to.throw( + expect(() => parseSearchFilters('{"field":"users.net_worth_usd"}')).to.throw( /valid JSON array of FilterCondition/, ); }); it('throws when an entry is missing a string field', function () { - expect(() => parseSearchConditions('[{"op":"gt","value":1}]')).to.throw( + expect(() => parseSearchFilters('[{"op":"gt","value":1}]')).to.throw( /must have a non-empty string "field"/, ); }); @@ -460,14 +460,14 @@ describe('commands / body builders', function () { describe('buildCreateSegmentBody() validation', function () { it('rejects non-array JSON', function () { expect(() => - buildCreateSegmentBody({ title: 'x', filterSets: '{"a":1}' }), + buildCreateSegmentBody({ title: 'x', filters: '{"a":1}' }), ).to.throw(/must be a valid JSON array/); }); - it('rejects arrays containing non-string entries', function () { + it('rejects entries without canonical field/op keys', function () { expect(() => - buildCreateSegmentBody({ title: 'x', filterSets: '[{"a":1}]' }), - ).to.throw(/JSON array of strings/); + buildCreateSegmentBody({ title: 'x', filters: '[{"a":1}]' }), + ).to.throw(/requires non-empty string "field" and "op"/); }); }); diff --git a/test/commands/profiles.test.ts b/test/commands/profiles.test.ts index 0f7ffb8..e9620d3 100644 --- a/test/commands/profiles.test.ts +++ b/test/commands/profiles.test.ts @@ -46,16 +46,16 @@ describe('commands/profiles', function () { expect(result).to.exist; }); - it('throws on invalid conditions JSON', function () { + it('throws on invalid filters JSON', function () { expect(() => - searchProfilesRun({ conditions: 'not-json' }), - ).to.throw(/conditions/); + searchProfilesRun({ filters: 'not-json' }), + ).to.throw(/filters/); }); - it('throws when conditions is not a JSON array', function () { + it('throws when filters is not a JSON array', function () { expect(() => - searchProfilesRun({ conditions: '{"field":"x"}' }), - ).to.throw(/conditions/); + searchProfilesRun({ filters: '{"field":"x"}' }), + ).to.throw(/filters/); }); }); diff --git a/test/commands/segments.test.ts b/test/commands/segments.test.ts index 5bc2521..baea5da 100644 --- a/test/commands/segments.test.ts +++ b/test/commands/segments.test.ts @@ -16,14 +16,23 @@ describe('commands/segments', function () { }); describe('createSegmentRun() — local validation', function () { - it('throws on invalid --filter-sets JSON', function () { - expect(() => createSegmentRun({ title: 'x', filterSets: 'not-json' })).to.throw(/filter-sets/); + it('throws on invalid --filters JSON', function () { + expect(() => createSegmentRun({ title: 'x', filters: 'not-json' })).to.throw(/filters/); }); - it('throws when --filter-sets is valid JSON but not an array', function () { - expect(() => createSegmentRun({ title: 'x', filterSets: '{"a":1}' })).to.throw(/filter-sets/); - expect(() => createSegmentRun({ title: 'x', filterSets: '5' })).to.throw(/filter-sets/); - expect(() => createSegmentRun({ title: 'x', filterSets: '"foo"' })).to.throw(/filter-sets/); + it('throws when --filters is valid JSON but not an array', function () { + expect(() => createSegmentRun({ title: 'x', filters: '{"a":1}' })).to.throw(/filters/); + expect(() => createSegmentRun({ title: 'x', filters: '5' })).to.throw(/filters/); + expect(() => createSegmentRun({ title: 'x', filters: '"foo"' })).to.throw(/filters/); + }); + + it('rejects legacy DSL strings', function () { + expect(() => + createSegmentRun({ + title: 'x', + filters: '["net_worth_usd::gt::100000"]', + }), + ).to.throw(/field, op, value/); }); }); }); diff --git a/test/lib/parseApiError.test.ts b/test/lib/parseApiError.test.ts index db33aad..c8b56cb 100644 --- a/test/lib/parseApiError.test.ts +++ b/test/lib/parseApiError.test.ts @@ -72,7 +72,7 @@ describe('lib/client / parseApiError', function () { it('preserves details on validation errors', function () { const details = { 'body.name': 'Required', - 'body.conditions.0.operator': 'Expected one of: gt, lt, eq', + 'body.filters.0.op': 'Expected one of: gt, lt, eq', }; const err = parseApiError( makeAxiosError({ @@ -92,7 +92,7 @@ describe('lib/client / parseApiError', function () { expect(err.details).to.deep.equal(details); expect(err.message).to.include('Details: body.name: Required'); expect(err.message).to.include( - 'body.conditions.0.operator: Expected one of: gt, lt, eq', + 'body.filters.0.op: Expected one of: gt, lt, eq', ); });