Changelog

0.7.0 — Data-table totals row, formula-column filter fix, no more rows lost past the 1000-row cap

Every data-table view gets a per-column summary footer with type-aware aggregations (Sum, Avg, Min, Max, Median, Earliest, Latest, Most common, %) scoped to the filtered rows and saved per view. A view filtering on a formula column now matches; edited views no longer stay dirty forever after saving. And user tables with more than ~1000 active rows no longer silently drop the tail — allocator moves to a DB trigger, list walks the whole set with a stable tiebreaker, and the footer flags partial results.

What's new

Totals row on every data-table view

Every data-table view now carries a per-column summary footer — the Airtable / Notion pattern, one aggregation picker per column scoped to that column's type:

TypeMenu
allFilled, Empty, Unique, % Filled, % Empty
number price percent formula child-link+ Sum, Avg, Min, Max, Median, Range
date datetime+ Earliest, Latest, Range
boolean+ Checked, Unchecked, % Checked
select multi-select+ Most common

Percent columns list Avg first — Sum stays available for allocation-style columns that should total 100%, but summing a rate column is almost always a mistake.

Scoped to the filtered rows, never the page. Paging can't move a total; filtering always does. So the number under a filtered view reflects that view's rows, not just the ones on screen.

Stored per view rather than per column. A view is a saved perspective — "Won Deals" can total value where "All Leads" counts records. Changing a summary marks the view dirty and saves through the existing Save button; it never auto-saves. The picker persists onto filter_presets.summaries (migration 159).

Never shows a number it can't stand behind. Results carry a { value, state, reason } shape:

  • ok — exact for the rows we have.
  • partial — the row fetch was truncated; the value shows with a marker and a tooltip rather than passing as complete. Truncation is detected by comparing the rows returned against an exact count in the same round-trip, so it catches clipping from our own limit or PostgREST's db-max-rows without the client needing to know either.
  • uncomputable — a formula / child-link column above the existing 5,000-row cap; renders , not a guess.

See Data app → Totals row.

Fixes

Filtering on a formula column now matches

A view that filtered on a plain formula column previously matched nothingevalTableRule only special-cased child-link formulas, so a rule on a formula column compared against an always-undefined row.data[key]. The filter picker offered formula columns, so this was a filter the engine silently couldn't honor: it emptied the view and the sidebar's record count for it. Now evaluated the same way the sort path already did.

If the formula context isn't available at eval time (rare — a sort-only code path), the rule fails closed rather than widening a saved view to every row.

Formula values compare as text for equals / not_equals, matching the existing child-link-formula branch. Range operators (>, <, between) coerce numerically. So expected_commission equals 100 won't match 100.00 — numeric formula equality is a deliberate follow-up.

Views no longer stay dirty forever after saving

The dirty dot lingered on the title even right after Save, because isDirty compared raw JSON including node ids, and toFilterTree regenerates node ids on every normalization — so the post-save re-snapshot never matched the newly-normalized live tree. The comparison now uses the same id-stripping viewContentKey the external-edit path already used, so Save clears the dot immediately.

User tables past ~1000 rows no longer silently drop the tail

Records created in a user table with more than ~1000 active rows saved correctly and then never appeared in the grid — they were in the database, sitting just past the fetch ceiling. Three overlapping bugs, all fixed:

  • The 1000-row fetch ceiling. userTableRows.list issued one un-ranged select, and PostgREST caps any single response at its db-max-rows (1000 on Supabase) regardless of the .limit() requested. The list now walks with .range() up to ROW_FETCH_LIMIT, with an id tiebreaker so page boundaries stay stable when sort_order values collide.
  • The allocator read a truncated cache. addRow computed sort_order as max+1 over the browser cache — only ever a prefix of the table. Once the real maximum was off-screen, every insert reused the same value. Allocation moves to a BEFORE INSERT trigger (migration 160) that sees every row, guarded by a per-table advisory lock. Migration 161 drops the column's DEFAULT 0 — Postgres applies column defaults before BEFORE INSERT triggers, so the default silently pinned every new row to 0 before the trigger could fire.
  • convert_rows copied sort_order across tables. It wrote the source table's value into the target table's numbering space — the origin of 27 of 27 collision groups seen in this database. Converted rows now append to the target's tail; the original value is still recorded in record_conversions.source_sort_order, which is what undo_conversion restores from.

The truncation flag was already computed and plumbed into DataTableViewerWidget, but never rendered — the footer read "1000 records" for a 1002-row table. Both table widgets now mark a clipped row set as partial in the footer count, matching the new totals-row partial state.

Under the hood

  • filter_presets.summaries (migration 159) — jsonb map of { [columnKey]: { agg } } scoped to the preset, so summaries save and share exactly like filters and sorts already do.
  • user_table_rows.sort_order allocator (migrations 160 + 161) — BEFORE INSERT trigger backed by a per-table advisory lock; create_record_with_children and convert_rows defer to it. Backfill re-sequences the 27 collision groups (relative order preserved, ordered by existing sort_order first). No unique index on (table_id, sort_order)undo_conversion deliberately restores a row to a slot another row may since have taken.
  • Rollup batching narrowed. The parent-id key is built only when a child-link column exists (previously built for every table), the rollup fetch widens when a child-link column is summarized, and p_parent_ids is chunked at 1000.
  • formatCellValue and friends lifted out of DataTableViewerWidget into lib/dataTable/formatCell — the footer needs them and the widget imports the footer, so leaving them in place would have been an import cycle. Four existing importers re-export from the new home unchanged.
  • NUMERIC_COLUMN_TYPES shared with ChildLinkConfigPanel so the two can't drift when a numeric column type is added; the menus stay separate (they genuinely differ).

What's next

  • Custom summary equations{Revenue:sum} / {Deals:filled}, as a separate concept over column aggregates. Deliberately not in this release; shipping them first would push everyone to hand-write sum({Amount}) for the common case.
  • Numeric formula-column equality. equals / not_equals on a formula column compares as text today; range operators (>, <, between) already coerce numerically.

See Data app and Data tables (core concept).

On this page