Skip to content

fix(plugins): correct plugin-storage query/count/index/order on Postgres - #1

Open
vedanshujain wants to merge 2 commits into
test/plugin-storage-postgres-regressionfrom
fix/plugin-storage-postgres-jsonb
Open

fix(plugins): correct plugin-storage query/count/index/order on Postgres#1
vedanshujain wants to merge 2 commits into
test/plugin-storage-postgres-regressionfrom
fix/plugin-storage-postgres-jsonb

Conversation

@vedanshujain

Copy link
Copy Markdown
Owner

Summary

Fixes plugin storage query(), count(), unique/expression index creation, and orderBy on Postgres. The _plugin_storage.data column is text, but the query path extracted JSON fields with the Postgres ->> operator and no cast, so plugin storage was effectively broken and untested on Postgres (only SQLite was exercised). This is a prerequisite for atomic conditional writes (insert/updateIf), which need a correct numeric guard on Postgres.

The bugs (all verified on live Postgres 16)

  1. Parse failure: text ->> 'field' has no operator in Postgres → operator does not exist: text ->> unknown. Every plugin-storage query()/count() and the CREATE [UNIQUE] INDEX … ((data->>'field')) DDL errored on PG.
  2. Chained-comparison syntax error: the query()/count() where-splice wrapped each condition as <cond> = 1, which is boolean = integer on PG → syntax error at or near "=". No plugin-storage query ran on PG regardless of extraction.
  3. Lexical vs numeric comparison: even once parseable, (data::jsonb)->>'field' is text, so a numeric range guard like stock >= 10 compared lexically ('9' >= '10' is TRUE) → silent over-counting / oversell.
  4. ::numeric totality: a naive cast throws on any row storing a non-number in that field, and diverged from SQLite.
  5. orderBy: numeric fields sorted lexically on PG ([10,100,9]).

The fix (scoped to the _plugin_storage.data path only — generic jsonExtractExpr untouched)

  • pluginDataExtractExpr(db, field, { numeric }): PG (data::jsonb)->>'field'; for numeric comparison operands a type-guarded cast — CASE WHEN jsonb_typeof((data::jsonb)->'field')='number' THEN ((data::jsonb)->>'field')::numeric END (SQLite: CASE WHEN json_type(data,'$.field') IN ('integer','real') THEN json_extract(…) END). Non-number values evaluate to NULL → excluded, no throw, identical on both dialects.
  • pluginDataOrderExpr: orderBy uses jsonb-native ordering (data::jsonb)->'field' on PG (numeric-among-numbers, total, never throws); SQLite keeps json_extract.
  • buildRawWhereExpression: replaces the = 1 wrapper with a bare-boolean RawBuilder<SqlBool> .where(...) (same ?/param interleave; SQLite SQL semantically unchanged), DRYing the query/count splice.
  • count() coerces PG's bigint COUNT(*) string with Number(...).

SQLite behavior is unchanged except that a string-stored value like "10" no longer satisfies a numeric guard (previously allowed via storage-class ordering) — now standardized to match Postgres. Numeric predicates fall back to a sequential scan on Postgres (the text expression index can't satisfy a ::numeric predicate; field types aren't known at index-creation) — documented in storage-indexes.ts.

Tests

New dialect-parameterized suite storage-postgres-query.test.ts (runs on SQLite and Postgres via describeEachDialect + EMDASH_TEST_PG): equality, numeric RangeFilter across the multi-digit boundary (9/10/100, gte:10 → {10,100}), in, startsWith, count with numeric range, unique-index creation + enforcement on PG, numeric orderBy ([9,10,100]), heterogeneous-field totality (number vs "abc" → numeric row only, no throw, parity), boolean-eq, mixed in, negative/zero/float, null-field. Existing SQLite unit exact-SQL assertions updated to the new CASE form. Full plugin + dialect-compat run green on both dialects; typecheck / lint / format clean.

Notes

  • Reviewed independently by two engineers; both approve.
  • No new migration — forward-only; SQLite/D1 store JSON-as-text unchanged.
  • emdash: patch changeset included.

Ubuntu added 2 commits July 19, 2026 13:07
…nb + numeric cast)

Plugin storage keeps documents in `_plugin_storage.data`, a plain `text`
column. On Postgres the query/count/index paths extracted fields with the
JSON operator `->>` and no cast, which broke in two ways:

1. Parse failure: `text ->> 'x'` has no operator in Postgres, so `query()`,
   `count()`, and the UNIQUE/expression index DDL raised
   "operator does not exist: text ->> unknown".
2. Lexical comparison: even once parseable, `(data::jsonb)->>'field'` is
   still `text`, so a numeric guard like `stock >= 10` compared lexically
   (`'9' >= '10'` is TRUE) and silently over-counted / oversold.

Fix, scoped to the `_plugin_storage.data` path only (the generic
`jsonExtractExpr` used by real json/jsonb content columns is untouched):

- Add `pluginDataExtractExpr(db, field, { numeric })`: on Postgres emits
  `(data::jsonb)->>'field'`, and `((data::jsonb)->>'field')::numeric` when
  the bound operand is numeric; SQLite keeps `json_extract` (already typed).
- `buildCondition` decides numeric vs text per condition from the JS type of
  the bound value (number, all-number `in` list, per-bound RangeFilter);
  string/boolean/startsWith stay text. Index expressions extract as text only
  (uniqueness is on the textual value).
- The raw-WHERE splice in `query()`/`count()` no longer wraps the condition in
  `= 1`: Postgres parses `<cond> = 1` as a chained comparison (syntax error)
  and rejects `boolean = integer`. The condition is passed to `.where()` as a
  bare boolean, valid on both dialects; the `?`/params contract is preserved.
- `count()` coerces the result to a number (Postgres returns bigint COUNT as a
  string), honouring its `Promise<number>` contract on both dialects.

Unblocks dialect-parameterized plugin-storage tests; adds a
`describeEachDialect` suite covering the numeric-range boundary, `in`,
`startsWith`, equality, count, and unique/expression index creation on both
SQLite and Postgres.
…ally on Postgres

Addresses review findings on the query()/count() path this PR fixes:

1. orderBy sorted numeric fields lexically on Postgres. `ORDER BY
   (data::jsonb)->>'field'` sorts [10, 100, 9] on PG while SQLite's
   json_extract sorts [9, 10, 100]. Add `pluginDataOrderExpr`, which orders
   over the jsonb-native value `(data::jsonb)->'field'` — numeric among
   numbers, lexical among strings, and total across heterogeneous data (never
   throws). SQLite keeps json_extract (already numeric). orderBy in query() and
   buildOrderByClause now route through it.

2. The numeric guard threw on a single non-number stored value and diverged
   from SQLite: `((data::jsonb)->>'field')::numeric` raises
   "invalid input syntax for type numeric" the moment any scanned row stores a
   non-number, aborting the whole query, whereas SQLite silently compared.
   Guard the cast with a type check on both dialects so a non-number yields
   NULL (no match), never an error, with identical results:
     PG:     CASE WHEN jsonb_typeof((data::jsonb)->'f')='number'
               THEN ((data::jsonb)->>'f')::numeric END
     SQLite: CASE WHEN json_type(data,'$.f') IN ('integer','real')
               THEN json_extract(data,'$.f') END
   This intentionally changes the numeric-comparison SQL on both dialects (for
   parity); the exact-SQL unit assertions are updated accordingly.

3. Document that numeric predicates fall back to a sequential scan on Postgres
   (the per-field expression index is text-typed; field types aren't known at
   index-creation) — comment in storage-indexes.ts and a note in the changeset.

Tests (describeEachDialect, both dialects): numeric orderBy asserts [9,10,100]
without .toSorted() masking; heterogeneous field (5 vs "abc") returns only the
numeric row with no throw on either dialect; plus boolean equality (PG — bound
JS boolean vs text 'true'/'false'; better-sqlite3 can't bind booleans),
mixed-type `in` text fallback, negative/zero/float numeric guards, and a
null-field IS NULL match.
@github-actions

Copy link
Copy Markdown

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@github-actions github-actions Bot added overlap review/needs-review No maintainer or bot review yet labels Jul 21, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.

If you're still working on this, please push an update or leave a comment.

@github-actions github-actions Bot added stale and removed stale labels Aug 4, 2026
@github-actions github-actions Bot added stale and removed stale labels Aug 19, 2026
@github-actions github-actions Bot added stale and removed stale labels Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant