From f4c35847fe15409d7bdc23e615b7ebca32940a4c Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 29 May 2026 16:21:49 +0100 Subject: [PATCH 1/6] feat(events): add columnar row filter helper for json-layouts/json-values (FT-1969) --- src/core/events/json-filter.test.ts | 116 ++++++++++++++++++++++++++++ src/core/events/json-filter.ts | 72 +++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/core/events/json-filter.test.ts create mode 100644 src/core/events/json-filter.ts diff --git a/src/core/events/json-filter.test.ts b/src/core/events/json-filter.test.ts new file mode 100644 index 0000000..13b3fa1 --- /dev/null +++ b/src/core/events/json-filter.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { filterColumnarRows, hasColumnarFilters } from './json-filter.js'; + +// Mimics the json-layouts response shape (key/value_type/last_event_at). +const layouts = () => ({ + columnNames: ['key', 'value_type', 'last_event_at'], + columnTypes: ['String', 'String', 'Int64'], + rows: [ + ['currency', 'string', 1], + ['items', 'array', 2], + ['items/0', 'object', 3], + ['items/0/segment_flight_number', 'string', 4], + ['pageName', 'string', 5], + ] as unknown[][], +}); + +// Mimics the json-values response shape (value/last_event_at). +const values = () => ({ + columnNames: ['value', 'last_event_at'], + columnTypes: ['String', 'Int64'], + rows: [ + ['LA3027 / LA3528 / LA8072', 1], + ['LA8186', 2], + ['DL2702 / LA8185', 3], + ['', 4], + ] as unknown[][], +}); + +const keys = (d: { rows: unknown[][] }) => d.rows.map((r) => r[0]); + +describe('hasColumnarFilters', () => { + it('is false when no filter option is set', () => { + expect(hasColumnarFilters({})).toBe(false); + }); + it('is true when any filter option is set', () => { + expect(hasColumnarFilters({ match: 'x' })).toBe(true); + expect(hasColumnarFilters({ topLevel: true })).toBe(true); + expect(hasColumnarFilters({ maxDepth: 2 })).toBe(true); + }); +}); + +describe('filterColumnarRows - match (regex, case-insensitive)', () => { + it('filters the key column by case-insensitive regex', () => { + const out = filterColumnarRows(layouts(), 'key', { match: 'PAGE' }) as ReturnType< + typeof layouts + >; + expect(keys(out)).toEqual(['pageName']); + }); + it('supports regex alternation', () => { + const out = filterColumnarRows(layouts(), 'key', { + match: 'segment_flight|currency', + }) as ReturnType; + expect(keys(out)).toEqual(['currency', 'items/0/segment_flight_number']); + }); + it('filters the value column for json-values', () => { + const out = filterColumnarRows(values(), 'value', { match: 'LA8186|LA8153' }) as ReturnType< + typeof values + >; + expect(out.rows.map((r) => r[0])).toEqual(['LA8186']); + }); + it('throws a clear error on invalid regex', () => { + expect(() => filterColumnarRows(layouts(), 'key', { match: '(' })).toThrow( + /Invalid --match regex/ + ); + }); +}); + +describe('filterColumnarRows - depth', () => { + it('--top-level keeps only paths with no slash', () => { + const out = filterColumnarRows(layouts(), 'key', { topLevel: true }) as ReturnType< + typeof layouts + >; + expect(keys(out)).toEqual(['currency', 'items', 'pageName']); + }); + it('--max-depth N keeps paths with at most N segments', () => { + const out = filterColumnarRows(layouts(), 'key', { maxDepth: 2 }) as ReturnType; + expect(keys(out)).toEqual(['currency', 'items', 'items/0', 'pageName']); + }); + it('topLevel takes precedence over maxDepth', () => { + const out = filterColumnarRows(layouts(), 'key', { topLevel: true, maxDepth: 3 }) as ReturnType< + typeof layouts + >; + expect(keys(out)).toEqual(['currency', 'items', 'pageName']); + }); + it('throws on a non-positive / non-integer max-depth', () => { + expect(() => filterColumnarRows(layouts(), 'key', { maxDepth: 0 })).toThrow(/max-depth/); + expect(() => filterColumnarRows(layouts(), 'key', { maxDepth: 1.5 })).toThrow(/max-depth/); + }); +}); + +describe('filterColumnarRows - composition & defensive behavior', () => { + it('composes match AND depth', () => { + const out = filterColumnarRows(layouts(), 'key', { match: 'items', maxDepth: 2 }) as ReturnType< + typeof layouts + >; + expect(keys(out)).toEqual(['items', 'items/0']); + }); + it('returns the data unchanged when no filters are active', () => { + const d = layouts(); + expect(filterColumnarRows(d, 'key', {})).toBe(d); + }); + it('returns data unchanged when the column is absent', () => { + const d = layouts(); + expect(filterColumnarRows(d, 'nope', { match: 'x' })).toBe(d); + }); + it('returns data unchanged for non-columnar shapes', () => { + const weird = { foo: 'bar' }; + expect(filterColumnarRows(weird, 'key', { match: 'x' })).toBe(weird); + }); + it('does not mutate the input rows', () => { + const d = layouts(); + const before = d.rows.length; + filterColumnarRows(d, 'key', { topLevel: true }); + expect(d.rows.length).toBe(before); + }); +}); diff --git a/src/core/events/json-filter.ts b/src/core/events/json-filter.ts new file mode 100644 index 0000000..2a39373 --- /dev/null +++ b/src/core/events/json-filter.ts @@ -0,0 +1,72 @@ +// Client-side post-processing for the columnar results returned by +// `events json-layouts` and `events json-values` +// (shape: { columnNames, columnTypes, rows }). Filters the rows against one +// named column without changing the columnar shape, so output formatting and +// `-o json` are unaffected. + +export interface ColumnarFilterOptions { + /** Case-insensitive regex tested against the target column's stringified cell. */ + match?: string | undefined; + /** Keep only depth-1 paths (no '/'). Equivalent to maxDepth = 1; takes precedence. */ + topLevel?: boolean | undefined; + /** Keep only paths with at most this many '/'-separated segments. */ + maxDepth?: number | undefined; +} + +interface Columnar { + columnNames: string[]; + columnTypes?: string[]; + rows: unknown[][]; +} + +function isColumnar(data: unknown): data is Columnar { + const d = data as Columnar | null; + return !!d && Array.isArray(d.columnNames) && Array.isArray(d.rows); +} + +export function hasColumnarFilters(opts: ColumnarFilterOptions): boolean { + return opts.match !== undefined || opts.topLevel === true || opts.maxDepth !== undefined; +} + +/** + * Filter the `rows` of a columnar result by a named column. + * - `match`: case-insensitive regex the column cell must match. + * - `topLevel` / `maxDepth`: limit path depth (segments split on '/'). + * Filters compose with AND. Returns the input untouched when no filter is + * active, the data is not columnar, or the column is absent. + */ +export function filterColumnarRows( + data: unknown, + column: string, + opts: ColumnarFilterOptions +): unknown { + if (!hasColumnarFilters(opts)) return data; + + // Validate inputs up front so bad flags fail fast regardless of data shape. + let regex: RegExp | undefined; + if (opts.match !== undefined) { + try { + regex = new RegExp(opts.match, 'i'); + } catch (e) { + throw new Error(`Invalid --match regex: ${(e as Error).message}`); + } + } + if (opts.maxDepth !== undefined && (!Number.isInteger(opts.maxDepth) || opts.maxDepth < 1)) { + throw new Error('--max-depth must be a positive integer'); + } + + if (!isColumnar(data)) return data; + const colIdx = data.columnNames.indexOf(column); + if (colIdx === -1) return data; + + const depthLimit = opts.topLevel ? 1 : opts.maxDepth; + + const rows = data.rows.filter((row) => { + const cell = String(row[colIdx] ?? ''); + if (regex && !regex.test(cell)) return false; + if (depthLimit !== undefined && cell.split('/').length > depthLimit) return false; + return true; + }); + + return { ...data, rows }; +} From 3e0f72fbd25108192a4af9f105bbe323175f1a6a Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 29 May 2026 16:27:38 +0100 Subject: [PATCH 2/6] feat(events): add --match/--top-level/--max-depth to json-layouts (FT-1969) --- src/commands/events/events.test.ts | 77 ++++++++++++++++++++++++++++++ src/commands/events/index.ts | 12 ++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/commands/events/events.test.ts b/src/commands/events/events.test.ts index f0d41c3..7c17565 100644 --- a/src/commands/events/events.test.ts +++ b/src/commands/events/events.test.ts @@ -202,6 +202,83 @@ describe('events command', () => { expect(printFormatted).toHaveBeenCalled(); }); + const columnarLayouts = { + columnNames: ['key', 'value_type', 'last_event_at'], + columnTypes: ['String', 'String', 'Int64'], + rows: [ + ['currency', 'string', 1], + ['items', 'array', 2], + ['items/0', 'object', 3], + ['items/0/segment_flight_number', 'string', 4], + ['pageName', 'string', 5], + ], + }; + + it('filters json-layouts paths by --match (case-insensitive regex)', async () => { + mockClient.getEventJsonLayouts.mockResolvedValueOnce(columnarLayouts); + await eventsCommand.parseAsync([ + 'node', + 'test', + 'json-layouts', + '--source', + 'unit_goal_property', + '--phase', + 'after_enrichment', + '--match', + 'PAGE|segment_flight', + ]); + const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as { rows: unknown[][] }; + expect(printed.rows.map((r) => r[0])).toEqual(['items/0/segment_flight_number', 'pageName']); + }); + + it('filters json-layouts paths by --top-level', async () => { + mockClient.getEventJsonLayouts.mockResolvedValueOnce(columnarLayouts); + await eventsCommand.parseAsync([ + 'node', + 'test', + 'json-layouts', + '--source', + 'unit_goal_property', + '--phase', + 'after_enrichment', + '--top-level', + ]); + const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as { rows: unknown[][] }; + expect(printed.rows.map((r) => r[0])).toEqual(['currency', 'items', 'pageName']); + }); + + it('filters json-layouts paths by --max-depth', async () => { + mockClient.getEventJsonLayouts.mockResolvedValueOnce(columnarLayouts); + await eventsCommand.parseAsync([ + 'node', + 'test', + 'json-layouts', + '--source', + 'unit_goal_property', + '--phase', + 'after_enrichment', + '--max-depth', + '2', + ]); + const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as { rows: unknown[][] }; + expect(printed.rows.map((r) => r[0])).toEqual(['currency', 'items', 'items/0', 'pageName']); + }); + + it('passes json-layouts output through unchanged when no client filter is set', async () => { + mockClient.getEventJsonLayouts.mockResolvedValueOnce(columnarLayouts); + await eventsCommand.parseAsync([ + 'node', + 'test', + 'json-layouts', + '--source', + 'unit_goal_property', + '--phase', + 'after_enrichment', + ]); + const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as { rows: unknown[][] }; + expect(printed.rows.length).toBe(5); + }); + it('should error on unit-data with invalid format (no colon)', async () => { await expect( eventsCommand.parseAsync(['node', 'test', 'unit-data', 'invalidformat']) diff --git a/src/commands/events/index.ts b/src/commands/events/index.ts index 4ee1648..0975a95 100644 --- a/src/commands/events/index.ts +++ b/src/commands/events/index.ts @@ -18,6 +18,7 @@ import { getEventJsonLayouts as coreGetEventJsonLayouts, parseUnits, } from '../../core/events/events.js'; +import { filterColumnarRows } from '../../core/events/json-filter.js'; import { summaryCommand } from './summary.js'; function parseNumberArray(value: string, previous: number[]): number[] { @@ -208,6 +209,10 @@ const jsonLayoutsCommand = new Command('json-layouts') .option('--source-id ', 'source ID', Number) .option('--from ', 'start time (e.g. 7d, 2w, 2026-01-01, epoch ms)') .option('--to ', 'end time (e.g. 7d, 2w, 2026-01-01, epoch ms)') + // Client-side filters applied to the returned paths. + .option('--match ', 'filter paths by case-insensitive regex (client-side)') + .option('--top-level', 'show only top-level paths (no slash); shorthand for --max-depth 1') + .option('--max-depth ', 'show only paths with at most N segments', (v) => parseInt(v, 10)) .action( withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(jsonLayoutsCommand); @@ -224,7 +229,12 @@ const jsonLayoutsCommand = new Command('json-layouts') from: jlFrom, to: jlTo, }); - printFormatted(result.data, globalOptions); + const filtered = filterColumnarRows(result.data, 'key', { + match: options.match as string | undefined, + topLevel: options.topLevel as boolean | undefined, + maxDepth: options.maxDepth as number | undefined, + }); + printFormatted(filtered, globalOptions); }) ); From 124acca49737964e80e9d25c7d46d06e820cb6d3 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 29 May 2026 19:03:06 +0100 Subject: [PATCH 3/6] feat(events): add --match filter to json-values (FT-1969) --- src/commands/events/events.test.ts | 43 ++++++++++++++++++++++++++++++ src/commands/events/index.ts | 7 ++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/commands/events/events.test.ts b/src/commands/events/events.test.ts index 7c17565..b1c1a9e 100644 --- a/src/commands/events/events.test.ts +++ b/src/commands/events/events.test.ts @@ -184,6 +184,49 @@ describe('events command', () => { expect(printFormatted).toHaveBeenCalled(); }); + const columnarValues = { + columnNames: ['value', 'last_event_at'], + columnTypes: ['String', 'Int64'], + rows: [ + ['LA3027 / LA3528 / LA8072', 1], + ['LA8186', 2], + ['DL2702 / LA8185', 3], + ['', 4], + ], + }; + + it('filters json-values by --match (case-insensitive regex on value)', async () => { + mockClient.getEventJsonValues.mockResolvedValueOnce(columnarValues); + await eventsCommand.parseAsync([ + 'node', + 'test', + 'json-values', + '--event-type', + 'goal', + '--path', + 'items/0/segment_flight_number', + '--match', + 'la8186|la8153', + ]); + const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as { rows: unknown[][] }; + expect(printed.rows.map((r) => r[0])).toEqual(['LA8186']); + }); + + it('passes json-values output through unchanged when no --match is set', async () => { + mockClient.getEventJsonValues.mockResolvedValueOnce(columnarValues); + await eventsCommand.parseAsync([ + 'node', + 'test', + 'json-values', + '--event-type', + 'goal', + '--path', + 'items/0/segment_flight_number', + ]); + const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as { rows: unknown[][] }; + expect(printed.rows.length).toBe(4); + }); + it('should get event json layouts', async () => { await eventsCommand.parseAsync([ 'node', diff --git a/src/commands/events/index.ts b/src/commands/events/index.ts index 0975a95..f63f542 100644 --- a/src/commands/events/index.ts +++ b/src/commands/events/index.ts @@ -181,6 +181,8 @@ const jsonValuesCommand = new Command('json-values') .option('--goal-id ', 'goal ID', Number) .option('--from ', 'start time (e.g. 7d, 2w, 2026-01-01, epoch ms)') .option('--to ', 'end time (e.g. 7d, 2w, 2026-01-01, epoch ms)') + // Client-side filter applied to the returned values. + .option('--match ', 'filter values by case-insensitive regex (client-side)') .action( withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(jsonValuesCommand); @@ -197,7 +199,10 @@ const jsonValuesCommand = new Command('json-values') from: jvFrom, to: jvTo, }); - printFormatted(result.data, globalOptions); + const filtered = filterColumnarRows(result.data, 'value', { + match: options.match as string | undefined, + }); + printFormatted(filtered, globalOptions); }) ); From 4798dd07dc4dc55b618af6cda6f066d9b92b8d3f Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 29 May 2026 19:08:09 +0100 Subject: [PATCH 4/6] docs(events): document --match/--top-level/--max-depth on json commands (FT-1969) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 6d46a28..2bd3328 100644 --- a/README.md +++ b/README.md @@ -1200,7 +1200,11 @@ abs events history --event-name my-experiment --env-type production abs events unit-data 1:user123 2:device456 abs events delete-unit-data 1:user123 abs events json-values --event-type exposure --path "variant" --experiment-id 123 +abs events json-values --event-type goal --path "items/0/segment_flight_number" --goal-id 164 --match "LA8186|LA8153" # filter values (regex, client-side) abs events json-layouts --source unit_attribute --phase after_enrichment +abs events json-layouts --source unit_goal_property --phase after_enrichment --source-id 164 --match segment_flight # filter paths (regex) +abs events json-layouts --source unit_goal_property --phase after_enrichment --source-id 164 --top-level # only top-level paths +abs events json-layouts --source unit_goal_property --phase after_enrichment --source-id 164 --max-depth 2 # paths up to 2 segments deep abs events summary --from month-start # this month, weekly buckets, totals (default) abs events summary --from last-month-start --to last-month-end --period month abs events summary --from 30d --group-by team --cumulative From d4c4526c47cb96ddca33318f1c8bc8c9b814ea32 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 29 May 2026 19:17:29 +0100 Subject: [PATCH 5/6] fix(events): warn instead of silently ignoring filters on unexpected response shape (FT-1969) --- src/commands/events/events.test.ts | 22 ++++++++++++++++++++++ src/core/events/json-filter.test.ts | 19 ++++++++++++++++--- src/core/events/json-filter.ts | 14 ++++++++++++-- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/commands/events/events.test.ts b/src/commands/events/events.test.ts index b1c1a9e..19597fd 100644 --- a/src/commands/events/events.test.ts +++ b/src/commands/events/events.test.ts @@ -322,6 +322,28 @@ describe('events command', () => { expect(printed.rows.length).toBe(5); }); + it('rejects an invalid --match regex with a clear error', async () => { + mockClient.getEventJsonLayouts.mockResolvedValueOnce(columnarLayouts); + try { + await eventsCommand.parseAsync([ + 'node', + 'test', + 'json-layouts', + '--source', + 'unit_goal_property', + '--phase', + 'after_enrichment', + '--match', + '(', + ]); + throw new Error('Should have thrown'); + } catch (error) { + if (!(error as Error).message.startsWith('process.exit')) throw error; + const errorOutput = consoleErrorSpy.mock.calls.flat().join(' '); + expect(errorOutput).toContain('Invalid --match regex'); + } + }); + it('should error on unit-data with invalid format (no colon)', async () => { await expect( eventsCommand.parseAsync(['node', 'test', 'unit-data', 'invalidformat']) diff --git a/src/core/events/json-filter.test.ts b/src/core/events/json-filter.test.ts index 13b3fa1..60f91b6 100644 --- a/src/core/events/json-filter.test.ts +++ b/src/core/events/json-filter.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { filterColumnarRows, hasColumnarFilters } from './json-filter.js'; // Mimics the json-layouts response shape (key/value_type/last_event_at). @@ -99,13 +99,26 @@ describe('filterColumnarRows - composition & defensive behavior', () => { const d = layouts(); expect(filterColumnarRows(d, 'key', {})).toBe(d); }); - it('returns data unchanged when the column is absent', () => { + it('returns data unchanged (with a stderr warning) when the column is absent', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); const d = layouts(); expect(filterColumnarRows(d, 'nope', { match: 'x' })).toBe(d); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('column "nope" not found')); + spy.mockRestore(); }); - it('returns data unchanged for non-columnar shapes', () => { + it('returns data unchanged (with a stderr warning) for non-columnar shapes', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); const weird = { foo: 'bar' }; expect(filterColumnarRows(weird, 'key', { match: 'x' })).toBe(weird); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('not in the expected columnar shape')); + spy.mockRestore(); + }); + it('does NOT warn when no filters are active, even on non-columnar data', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const weird = { foo: 'bar' }; + expect(filterColumnarRows(weird, 'key', {})).toBe(weird); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); }); it('does not mutate the input rows', () => { const d = layouts(); diff --git a/src/core/events/json-filter.ts b/src/core/events/json-filter.ts index 2a39373..097bd39 100644 --- a/src/core/events/json-filter.ts +++ b/src/core/events/json-filter.ts @@ -55,9 +55,19 @@ export function filterColumnarRows( throw new Error('--max-depth must be a positive integer'); } - if (!isColumnar(data)) return data; + if (!isColumnar(data)) { + console.error( + 'Warning: filter options ignored — response is not in the expected columnar shape; returning unfiltered results.' + ); + return data; + } const colIdx = data.columnNames.indexOf(column); - if (colIdx === -1) return data; + if (colIdx === -1) { + console.error( + `Warning: filter options ignored — column "${column}" not found in response (columns: ${data.columnNames.join(', ')}); returning unfiltered results.` + ); + return data; + } const depthLimit = opts.topLevel ? 1 : opts.maxDepth; From 4b9d562ca4ae619c51e53acc87e579134f4d7d4d Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 29 May 2026 19:26:03 +0100 Subject: [PATCH 6/6] chore(release): bump cli to 1.8.0 (FT-1969) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3c4cddf..9bc085c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@absmartly/cli", - "version": "1.7.0", + "version": "1.8.0", "description": "ABSmartly CLI - A/B Testing and Feature Flags command-line tool for AI agents and humans", "type": "module", "main": "./dist/index.js",