Conversation
…rsager#701 The builder keyword regexes used a backreference (\1) in their trailing negative lookahead, so the 'no later occurrence' assertion only rejected a repeat that matched the SAME delimiters as the first hit. ' in(' and ' in ' are different text, so a later ' in ' did not cancel an earlier ' in('. Builder.build picks the keyword with the highest index, so 'where x in(select ... where x in ${...}' kept the leading in(, ranked the '(select ' hit above it, and handed the array to the select helper, which threw TypeError: str.replace is not a function. Repeat the keyword pattern inside the lookahead instead of backreferencing the captured text, so any later occurrence counts regardless of which delimiters surround it.
746936f to
a8d5ffe
Compare
Verification — NOT verified, one blocking defectAdversarial verification of The production fix in I closed the PR's stated gap: a real Postgres WAS availableThe body says $ npm i --no-save --no-package-lock --ignore-scripts --legacy-peer-deps @electric-sql/pglite @electric-sql/pglite-socket
added 2 packages in 1s
$ node -e "console.log(require('@electric-sql/pglite/package.json').version)"
0.5.8
$ node server.mjs & # PGLiteSocketServer on 127.0.0.1:5432
READY
$ node -e "... postgres({db:'postgres_js_test',user:'postgres_js_test'})\`select 1 as x\`"
Result(1) [ { x: 1 } ]So every test below ran end-to-end against a real Postgres backend, executing the actual SQL — not just the string builder. (Caveat: this server accepts one connection per lifetime, so each arm restarts it.) BLOCKER — test 4
|
The single-keyword test built `x in((1,2))` - the template supplies the ( and the in builder emits its own ( ) - so the operand was a row constructor and Postgres rejected it with "operator does not exist: integer = record" on both arms. Compare a row constructor on both sides instead, which keeps the in( delimiter the test exists to cover. Also cover the cases the fix changes that no test pinned: a keyword other than in, the reverse ordering, three occurrences with three delimiters, and uppercase.
Rework — blocker fixed, ledger holes closedAddresses the verification block at 1. The blocker: test 4 built invalid SQL and failed on both arms
Applied REPAIR-B — a row constructor on both sides, which keeps the const [{ x }] = await sql`select x from test where (x,x) in(${ sql([[1, 1]]) })`Confirmed === BASE 411429e ===
row 7 single in( / test 4 (x,x) in( => "($1,$2)"
=== HEAD 097e474 ===
row 7 single in( / test 4 (x,x) in( => "($1,$2)"2. Four ledger holes closedEvery shipped test used 3. Both arms, real PostgreSQLThe body's "no database available" gap is closed — PGlite 0.5.8 (Postgres compiled to WASM) over ######## HEAD — 097e474 (fix applied) ########
PASS Last keyword used even with duplicate keywords (pre-existing)
PASS Last keyword used even when an earlier keyword is followed by (
PASS Last keyword used even when it ends the string
PASS Last keyword used when nested keywords are all spaced
PASS Single keyword followed by ( is still used
PASS Last keyword used for keywords other than in
PASS Last keyword used when the spaced keyword comes first
PASS Last keyword used with three occurrences and three delimiters
PASS Last keyword used case insensitively
🎉 all passed
######## BASE — 411429e (fix NOT applied) ########
PASS Last keyword used even with duplicate keywords (pre-existing)
FAIL Last keyword used even when an earlier keyword is followed by ( :: threw str.replace is not a function
FAIL Last keyword used even when it ends the string :: threw str.replace is not a function
PASS Last keyword used when nested keywords are all spaced
PASS Single keyword followed by ( is still used
FAIL Last keyword used for keywords other than in :: expected 3 != got undefined
FAIL Last keyword used when the spaced keyword comes first :: threw str.replace is not a function
FAIL Last keyword used with three occurrences and three delimiters :: threw str.replace is not a function
FAIL Last keyword used case insensitively :: threw str.replace is not a function
⚠️ 6 failed9 tests: 6 discriminate, 2 are controls that pass on both arms ( $ npx --yes eslint@8 src tests
$ echo "ESLINT_RC=$?"
ESLINT_RC=04. Body reconciled to this head
Verification items I did not change, per the findings: row 22 ( |
VerificationIndependent adversarial pass by a fresh run, at head Production source is untouched by this pass. Four ledger rows had no test; they do now
Row 31b is worth a maintainer's eye: on base the builder emits Both arms, verbatim, 13 tests######## HEAD — fix/builder-keyword-last-occurrence @ 280ff1d (fix applied) ########
PASS 0 Last keyword used even with duplicate keywords (pre-existing)
PASS 1 Last keyword used even when an earlier keyword is followed by (
PASS 2 Last keyword used even when it ends the string
PASS 3 Last keyword used when nested keywords are all spaced
PASS 4 Single keyword followed by ( is still used
PASS 5 Last keyword used for keywords other than in
PASS 6 Last keyword used when the spaced keyword comes first
PASS 7 Last keyword used with three occurrences and three delimiters
PASS 8 Last keyword used case insensitively
PASS V1 Last keyword used when the earlier keyword is in a nested fragment
PASS V3 Repeated keyword with identical ( delimiters (control)
PASS V4 Last keyword used when an earlier keyword is newline delimited
PASS V2 Last keyword used with an empty array
🎉 all passed
######## BASE — origin/master @ 411429e (fix NOT applied) ########
PASS 0 Last keyword used even with duplicate keywords (pre-existing)
FAIL 1 Last keyword used even when an earlier keyword is followed by ( :: threw str.replace is not a function
FAIL 2 Last keyword used even when it ends the string :: threw str.replace is not a function
PASS 3 Last keyword used when nested keywords are all spaced
PASS 4 Single keyword followed by ( is still used
FAIL 5 Last keyword used for keywords other than in :: expected 3 != got undefined
FAIL 6 Last keyword used when the spaced keyword comes first :: threw str.replace is not a function
FAIL 7 Last keyword used with three occurrences and three delimiters :: threw str.replace is not a function
FAIL 8 Last keyword used case insensitively :: threw str.replace is not a function
FAIL V1 Last keyword used when the earlier keyword is in a nested fragment :: threw str.replace is not a function
PASS V3 Repeated keyword with identical ( delimiters (control)
FAIL V4 Last keyword used when an earlier keyword is newline delimited :: threw str.replace is not a function
FAIL V2 Last keyword used with an empty array :: threw syntax error at or near ")"
⚠️ 9 failed9 discriminate, 3 are declared controls, 1 is the pre-existing baseline. The four base-passing rows are exactly the baseline plus the three controls — no test in this file passes on base without being named a control. A near-miss worth recordingThe V3 control failed on the base arm in the first run, which would have contradicted ledger row 9 and looked like a real finding. It was contamination, not discrimination: the empty-array test sends server-invalid SQL on base, and a server-side syntax error poisons the shared ===== BASE =====
V3 identical ( delims
SQL "select x from test where x in(select x from test where (x,x) in(($1,$2)))"
===== HEAD =====
V3 identical ( delims
SQL "select x from test where x in(select x from test where (x,x) in(($1,$2)))"The runner and the committed test order now place the server-error case last. On a shared connection, order-dependence looks exactly like discrimination. Tooling$ npx --yes eslint@8 src tests
$ echo "ESLINT_RC=$?"
ESLINT_RC=0
Body reconciled to this head: test counts, both transcripts, the test table, |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).
Verdict: APPROVED — ready for the operator to submit; no blocking issues found in the live head.
I reviewed the two-file diff and traced the base implementation at src/types.js:67-71,153-176. The changed regex removes the delimiter-sensitive backreference and repeats the keyword pattern in the negative lookahead, so Builder.build selects the last matching helper irrespective of whether the occurrences use space, (, or end-of-string delimiters. The added tests exercise the mixed-delimiter failure, reverse order, three occurrences, case handling, a non-in helper, nested fragments, newline delimiters, empty arrays, and controls.
For OSS-candidate readiness, I verified that the facts sheet contains all required sections; the base code exhibits the delimiter-sensitive \1 behavior; the body supplies real-server before/after evidence (13/13 at head, 9 failing at base); the boundary ledger covers the changed regex predicate; the prior-art searches found no competing upstream PR; and the commit messages have no AI attribution. The stated lint evidence is green. There are no fork CI checks configured, so the upstream Node/Postgres and CJS/Deno matrix remains operator-side CI confirmation rather than a green signal.
What's good: this is a minimal production change, with unusually strong regression coverage that proves both the reported failure and preservation of the same-delimiter paths.
sprayberry-secondread
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).
Verdict: no blocking issues. This is a clean, well-scoped, idiomatically consistent fix and I could independently confirm both the bug and the fix mechanism from the base source.
What I independently verified
I did not read the gating review. I re-derived everything below directly from porsager/postgres base @ 411429e and the fork head 280ff1d.
-
The bug is real and reproduces. Calling
Builderwith the base regex (((?:^|[\s(])in(?:$|[\s(]))(?![\s\S]*\1)) against"...where x in(select x from test where x in "picksselect@26as the winner, notin, because the\1backreference only cancels a later occurrence with an identical delimiter substring. I ran this against the actualsrc/types.jsBuilderclass in the fork (not a reimplementation) at bothsrc/types.js:176-179and confirmed the winner-selection flips fromselect→inbetween base and fixed regex for every mixed-delimiter case (paren-then-space, space-then-end, reverse order, case-insensitive, newline, CRLF, empty array under a repeated keyword). -
The fix (
src/types.js:176-179) is minimal and correct. Repeating the keyword pattern in the lookahead instead of backreferencing the captured text is the right generalization — it asserts "no later occurrence of this keyword" rather than "no later occurrence of this exact delimited substring." I confirmed it does not regress any of the same-delimiter/no-later-occurrence cases (single keyword, both same-paren, both same-space, keyword-only string, prefix/substring false-match guards, escaped-paren-outranks-in row) — all winners are byte-identical to base in those rows. -
Test 4 row-constructor risk (flagged by a prior fleet run against the intermediate head) is fixed in the shipped body. The current test 4 (
tests/index.js:2634-2640) uses(x,x) in(${ sql([[1,1]]) }), which I confirmed still selects theinbuilder and emits a valid, non-row-constructor comparison — good repair, matches what the PR body claims. -
Test V1's nested-fragment call path is real (
tests/index.js—sql\select x from test where x ${ sql`in(select x from test where x in ${ sql([1, 2]) })` }`). I confirmedstringifyValuereachesBuilder.buildthroughfragment()(src/types.js:107-118) as a second call path distinct from the top-level template, and that this path flips winner fromselect(throws) toin` between arms — this is a genuine coverage gap the original 9-test set would have missed. - No ReDoS regression. Repeating a keyword literal in a lookahead with no nested quantifier over an alternation does not introduce catastrophic backtracking; I measured both regex forms against 1k-200k character strings with no match and near-miss strings, both sub-5ms.
-
Prior art re-run.
gh search prs "701","backreference","builders"againstporsager/postgres, and a scan of all currently-open PRs (gh api .../pulls?state=open) for anything touchingsrc/types.js— none address this. Only porsager#1165 (Array.isArray(first[0])→Array.isArray(first)invalues()) touches the same file, and it's an unrelated fix to a different function, not in conflict.
Maintainer idiom check
- Fix shape matches the maintainer's own precedent. PR porsager#1198 ("Do not mutate parameters when serializing in Bind"), merged by the maintainer with the comment "Great PR and catch! Thank you!", is the closest analog: a one-line/few-line fix in
src/*.jsplus a batch oftests/index.jsregression tests appended immediately after the related existing test, using the exact samet('name', async() => {...})/ create-insert-assert-drop convention. This PR follows that shape precisely (tests/index.js:2615insertion point, right after the existingLast keyword used even with duplicate keywordstest). - No changelog entry — correct per convention.
CHANGELOG.mdin this repo is maintained by the author at release time (confirmed by inspecting the file: entries are one-liners with the merge commit's short SHA appended, clearly written post-merge, not part of the PR diff in porsager#1198 or porsager#1165 either). - PR title style matches. Compare to merged titles like "Fix wrong helper selection on multiple occurances" (
02f3854, the original regression commit) and "Fix: Do not mutate parameters when serializing in Bind" (porsager#1198) — imperative, "Fix ...", references the mechanism. The suggested titleFix wrong helper selection when a keyword is followed by ( - fixes #701fits. - One thing an upstream maintainer might push back on, though not blocking: the inline
// Control: ...comment above the "Repeated keyword with identical ( delimiters" test (tests/index.js:2698-2700) is a convention not used anywhere else intests/index.js— I grepped the full upstream test file and found zero//comments preceding any of the 264 existing tests. It's harmless and the intent is genuinely useful context, but it's a stylistic outlier the maintainer may trim or leave; not something I'd call a defect. - No CONTRIBUTING/PR template exists upstream (confirmed 404 on all the usual paths), so there's no description-shape rule to check against for the eventual upstream submission.
Break-it pass (boundaries rebuilt from the diff)
I rebuilt the winner-selection ledger myself from the two regex forms rather than reading the PR body's table first, then cross-checked. My independent run confirms the same five behavior-changing predicates the body claims (mixed-delimiter pairs in both orders, case-insensitive, newline/CRLF, empty-array-under-repeated-keyword, nested-fragment) and no additional ones. I did not find a reachable row the body misses. The \( vs in precedence bug (row 22 in the body, (a,b,c) in () is genuinely pre-existing and correctly out of scope — I confirmed it's identical on both arms (\(@30 wins both times) and traces to a different builder (c2fe67b), not the lookahead this PR touches.
Assertion strength
Spot-checked test 4 (single-in( control) and test V1 (nested fragment) directly against the real Builder class rather than trusting the body's transcript — both produce the claimed output on the fixed head. I did not re-run the full 13-test suite end-to-end against a live server myself (no local Postgres/PGlite setup attempted in this pass); the body's own two independent PGlite-backed runs (097e474 and 280ff1d) are the executed evidence for that layer, and I have no reason to doubt them given the mechanism checks out independently.
What's good
Small, surgical fix; the regex change is the minimum needed and preserves the exported shape (Builder/builders unchanged). Test coverage is unusually thorough for the size of the fix — it explicitly covers the "generic across all eight keywords" property (test 5 uses returning, not in) rather than testing only the reported keyword.
Disclosure section and the two prior adversarial passes are appropriately transparent about what's unexecuted (the real t() harness, cjs/deno transpiled arms) — I have nothing to add there.
SECOND READ: READY
|
Upstream PR opened; tracked in the ledger. |
Summary
src/types.jscompiles one regex per helper keyword (values,in,select,as,returning,\(,update,insert). Each was((?:^|[\s(])KW(?:$|[\s(]))(?![\s\S]*\1)— a capture group plus a backreference\1in the trailing "no later occurrence" lookahead." in("and" in "are different strings, so a later" in "did not cancel an earlier" in(". The stale earlier hit survived, andBuilder.build(which picks the highest index) then ranked the"(select "hit above it.selecthelper →escapeIdentifiers→escape()→str.replaceon a number, throwingTypeError: str.replace is not a function— upstream issue WHERE IN throwsTypeError: str.replace is not a functionporsager/postgres#701, open since 2023-10-17.src/types.js: hoist the keyword pattern into aconstand repeat it inside the lookahead instead of backreferencing the captured text, so any later occurrence counts regardless of surrounding delimiters.Last keyword used even with duplicate keywordstest — 9 discriminate (fail on base), 3 are declared controls (pass on both arms), and 1 is the pre-existing test re-run as a baseline.Every test below was executed end-to-end against a real PostgreSQL backend, on both arms, at the current head
280ff1d. Verbatim:The three rows that pass on both arms are deliberate controls:
...when nested keywords are all spaced(the same-delimiter spaced path must keep working),Single keyword followed by ( is still used(the single-keywordin(path must keep working), andRepeated keyword with identical ( delimiters(the case the backreference already handled correctly — it must not regress). Two base failures are notably not exceptions:Last keyword used for keywords other than inreturns a wrong answer (expected 3 != got undefined), andLast keyword used with an empty arrayis rejected by the server (syntax error at or near ")", from the emittedwhere x in )). A silently incorrect query and a server-rejected one are stronger arguments for the fix than a build-timeTypeError.Upstream
porsager/postgres(Postgres.js)master411429e7bd7a3d61155ca9a70a97c111823702ea("Do not mutate parameters when serializing in Bind")src/types.js(thebuildersregex compilation, line 176 on base),tests/index.jsbuilders(src/types.js:153-176), consumed byBuilder.build(src/types.js:67-72)TypeError: str.replace is not a functionporsager/postgres#701 — "WHERE IN throwsTypeError: str.replace is not a function"Bug
Trigger. Any tagged query whose text before a
sql([...])interpolation contains the same helper keyword twice with different surrounding delimiters — canonically a nestedin, e.g.sql`select x from test where x in(select x from test where x in ${sql([1,2])})`. Oneinis delimited" in(", the other" in ".Wrong outcome.
builderscompiles each keyword to((?:^|[\s(])KW(?:$|[\s(]))(?![\s\S]*\1). The lookahead is meant to assert "this is the last occurrence", but\1backreferences the captured text rather than the keyword pattern, so it only rejects a later occurrence that is delimited identically. With mixed delimiters the earlierinstill matches.Builder.buildsorts all keyword hits and takes the highest index; the surviving earlyin@23is then out-ranked by theselecthit at(select@26. The array[1,2]is routed to theselecthelper, reachingescapeIdentifiers→escapeIdentifier→str.replace(...)on a number, which throwsTypeError: str.replace is not a function.Instrumented keyword selection on base, verbatim:
Blast radius. Anyone using the documented dynamic-value helper (
README.md:280-289, "Dynamic values andwhere in") inside a nestedinsubquery, or with asql([...])where an earlier same-keyword occurrence is spaced differently. The query never reaches the server — it throws at build time, so it is a hard failure, not silent corruption. Regressed in v3.2.2 by commit02f3854("Fix wrong helper selection on multiple occurances"), which introduced the\1lookahead; reporters in porsager#701 hit it on 3.4.x. Three independent reporters in the thread (rytido,alonrbar,rozenmd), open ~2 years. The maintainer replied 2023-10-26: "Very nice tests @rytido !! Thank you - I'll look at fixing those and including your tests" — the tests below are built from exactly those cases.Repro
Two layers, both executed.
Layer 1 — the failure itself, against a real server. The pre-fix build fails the nested-
inshape reported in porsager#701. Transcript from the current 13-test file at head280ff1d, base arm:(The four base-passing rows — the pre-existing baseline and the three declared controls — are omitted above for brevity; the complete 13-line base transcript is in
## Summary.)Layer 2 — the keyword-selection mechanism, driving the real
Builder/stringifyexports directly, which isolates why:R2/R3 are
rytido's cases from the issue thread and are what this PR fixes. A1 is a different bug and is deliberately NOT fixed here — see## Boundariesrow 22 and the note at the end of## Fix.Note the headline snippet in the issue title (
where age in ${sql([68,75,23])}, README form) does not reproduce on current master — it returns($1,$2,$3)correctly. The live defect behind porsager#701 is the nested/mixed-delimiter shape reported in the thread's comments, which is what this PR targets.Fix
src/types.js, the.map()that compilesbuilders:The intent of
02f3854was "use the last occurrence of the keyword". Repeating the keyword pattern in the lookahead expresses that directly; the backreference expressed the narrower and unintended "no later occurrence with identical delimiters". The capture group is dropped because nothing else read it.Minimality: one expression, no behaviour added, no new dependency, no version bump, no changelog (repo requires none). It preserves the existing structure — still one regex per keyword, still consumed unchanged by
Builder.build.Alternatives rejected.
Builder.buildre-scan for the last match instead of relying on the lookahead — larger change to the hot path, and the lookahead is already the designed mechanism.lastIndexOf-style scanning — changessearch()semantics for all eight keywords and riskslastIndexstatefulness on shared regex objects.in— the defect is generic to every keyword (valuesshows the same index shift, row 25 of the ledger); special-casing would leave the others wrong.(a,b,c) IN (${...})(A1 above) — rejected as out of scope: different root cause, the'\\(': selectbuilder added byc2fe67blegitimately out-ranksinthere. It is a separate bug and belongs in its own PR; recorded as a lead, not fixed here.Test evidence
Thirteen tests added to
tests/index.jsimmediately after the existingLast keyword used even with duplicate keywordstest (tests/index.js:2605), matching that test's exact conventions (t('name', async() => {...}), create/insert/assert,return [expected, got, await sql`drop table test`]). Nine were added by the original hunt (097e474); the last four were added by an independent adversarial verification pass at head280ff1d, which did not changesrc/types.js(git diff 097e474 280ff1d -- src/types.jsis empty).Last keyword used even with duplicate keywords(pre-existing, unmodified)Last keyword used even when an earlier keyword is followed by (str.replace is not a functionLast keyword used even when it ends the stringstr.replace is not a functionLast keyword used when nested keywords are all spacedSingle keyword followed by ( is still usedin(path must stay workingLast keyword used for keywords other than inexpected 3 != got undefined(wrong answer, no throw)Last keyword used when the spaced keyword comes firststr.replace is not a functionLast keyword used with three occurrences and three delimitersstr.replace is not a functionLast keyword used case insensitivelystr.replace is not a functionLast keyword used when the earlier keyword is in a nested fragment(verification)str.replace is not a functionLast keyword used with an empty array(verification)syntax error at or near ")"(server-side)Repeated keyword with identical ( delimiters is unchanged (control)(verification)Last keyword used when an earlier keyword is newline delimited(verification)str.replace is not a functionTests 1 and 2 are
rytido's two failing cases from issue porsager#701; test 3 is that comment's working case, kept as a control. Test 4 controls the single-keywordin(path (## Boundariesrow 7). Tests 5-8 close ledger rows that the probe covered but no test pinned: test 5 proves the fix is notin-specific (it usesreturning, with a(-delimited occurrence inside a CTE and a spaced one after it), test 6 pins the reverse ordering, test 7 three occurrences with three delimiter shapes, and test 8 case-insensitivity of the repeated pattern.The four
Vtests close four further ledger rows that were probe-only before this pass:Builder.buildthroughfragment()— a nestedsql`...`fragment rather than the top-level template. That is a distinct production call path (stringifyValue→fragment→stringify), and it was entirely untested; it fails on base (row 32).where x in )and PostgreSQL itself rejects the statement. The previously shipped row 31 evidence covered only the single-keyword empty-array case via an existing test.(delimiters are the one shape the\1backreference handled correctly, so the fix must leave it alone. Both arms emit byte-identical SQL.[\s]delimiter class beyond a plain space) with a real newline between the query text and the keyword; probe-only before, fails on base now.Fails-before / passes-after for every one of the thirteen: the verbatim two-arm transcript in
## Summary.Method.
git stashis not usable across worktrees, so the two arms are two worktrees of the same clone: base is a detached worktree at411429e, head is the branch worktree at280ff1d. One runner script holds all thirteen test bodies copied verbatim out oftests/index.jsand is pointed at each worktree root in turn (import(root + '/src/index.js')), so the only variable between arms is the source tree.Lint — the repo's required tooling (
package.json:"lint": "eslint src && eslint tests"), re-run at the current head:Clean, no output, at head
280ff1d. (eslint 8 pinned to match the repo's.eslintrc.jsoneslintrc-format config.)Verification method
executed— against a real PostgreSQL backend, twice, by two independent runs, with one remaining gap named below.psql,docker,postgresorinitdb(command -v→ rc=127;/usr/lib/postgresqlabsent), so the repo's owntests/bootstrap.js(which shells out tocreatedb/psql) cannot run. A real Postgres is still reachable without them: PGlite is PostgreSQL compiled to WASM, and@electric-sql/pglite-socketexposes it on a TCP socket thatpostgres.jsconnects to over the ordinary wire protocol — real parser, real planner, real executor, real error messages.syntax error at or near ")"on base was observed. Plus the keyword-selection probe, the two failing repros, the 30-row boundary probe, the V-row SQL-rendering probe, the V3 isolation run, andeslint src tests(rc=0). Runtime: Node v24.19.0 on Linux, PGlite 0.5.8.097e474; a second, independent adversarial run rebuilt the## Boundariesledger from the diff (not from this body), re-measured both arms from scratch, added tests V1-V4 for four rows that had no test, and committed them as280ff1d.git diff 097e474 280ff1d -- src/types.jsis empty — the production change is byte-identical to the previously reviewed head; onlytests/index.jsgrew.npm run test:esmas a whole file (node tests/index.js) — the bootstrap needscreatedb/psqlto provision roles and thepostgres_js_testdatabase with extensions. The thirteen tests are run by the runner described above rather than bytests/test.js, so thet()harness wrapper itself is not exercised here; the test bodies are byte-identical to what is committed..github/workflows/test.yml, Node 12-24 × Postgres 12-17) runningnpm test— the thirteen tests green under the realt()harness, plustest:cjsandtest:deno, which run the transpiledcjs//deno/copies. The transpilers (transpile.cjs,transpile.deno.js) rewrite imports only; the changed expression is plain ES2020 and the.eslintrc.jsontarget ises2020, so no transpile-specific risk is expected — but the CJS/Deno arms are unverified here.gh pr checks 1 --repo askalf/postgresat280ff1d→no checks reported on the 'fix/builder-keyword-last-occurrence' branch. GitHub Actions are not enabled on this newly created fork (operator card filed to enable them). This is an absence of CI, not a failing CI — no job ran, green or red.Prior art
Searches run 2026-09-15, all against
porsager/postgres:gh pr list --search "701 in:body" --state all[]— nothing references the issuegh search prs "701"gh search prs "str.replace"gh search prs "escapeIdentifier"gh search prs "in helper"gh search prs "builders keyword"gh search prs "Builder build helper"gh search prs "where in"gh search prs "parenthesis"gh search issues "str.replace is not a function"gh pr list --state open --limit 60(scanned forsrc/types.js)builders/keyword selectionNo open or closed PR addresses this. Issue porsager#701 is OPEN, labelled
bug, no linked PR.Related open issues sharing the
str.replace is not a functionsymptom, checked and not claimed by this PR: porsager#1149 (twovaluesCTEs) — I reproduced its exact query and it builds correctly on base (with c(lat,lon) as (values ($1, $2)),p(name) as (values ($3),($4)) select 1), so its cause is elsewhere; porsager#962, porsager#913, porsager#777, porsager#820 not investigated. The commit message saysfixes #701only.Git-log mining that located the regression:
git log --oneline -20 -- src/types.jssurfaced02f3854"Fix wrong helper selection on multiple occurances" (the commit that added\1) andc2fe67b"Use select helper inside parenthesis" (which added the'\\(': selectbuilder responsible for the separate A1 bug).Policy
porsager/postgresships no contribution policy files. Fetched viagh api repos/porsager/postgres/contents/<path>, all HTTP 404:CONTRIBUTING.md,AGENTS.md,.github/CONTRIBUTING.md,.github/PULL_REQUEST_TEMPLATE.md,CODE_OF_CONDUCT.md,AI_POLICY.md,.github/AI_POLICY.md,AI.md,AGENT_POLICY.md,CLAUDE.md. The root tree confirms it (.eslintrc.json,CHANGELOG.md,README.md,UNLICENSE,cf,cjs,deno,package.json,src,tests,transpile.*,types);.github/contains onlyworkflows/.CHANGELOG.mdis maintained by the author at release time — untouched here).eslint src && eslint tests(frompackage.json"lint") → rc=0.npm test(test:esm+test:cjs+test:deno) requires a live Postgres and was not run — stated plainly in## Verification method.Disclosure facts for the operator
Plain facts, for you to write your own disclosure in your own words:
TypeError: str.replace is not a functionporsager/postgres#701; the agent determined the issue's headline snippet no longer reproduces, read the comment thread, and traced the real cause to the backreference in thebuilderslookahead.02f3854, v3.2.2) by mininggit log -- src/types.js.src/types.jsand all 13 regression tests intests/index.js.eslint src tests(rc=0) — all on Node v24.19.0 in a Linux container.tests/test.jsharness or thecjs/denoarms: the bootstrap needscreatedb/psql, which the container lacks. Upstream CI is what confirms those.Single keyword followed by ( is still usedtest built invalid SQL (x in((1,2)), a row constructor) and failed on both arms while the body claimed it passed; it also found four untested boundary rows. The second rebuilt the boundary ledger from the diff, re-measured both arms from scratch, and added four more tests (nested fragment, newline delimiter, empty array under a repeated keyword, and a same-delimiter control) for rows that had been probe-only. Neither review changedsrc/types.js— the production fix has been byte-identical since it was first written.rytido's comment on issue WHERE IN throwsTypeError: str.replace is not a functionporsager/postgres#701 (2023-10-24), which the maintainer explicitly asked to include.Boundaries
Every row below is an executed probe, not an argument. The diff changes exactly one expression — the regex compiled per keyword — so the predicate under test is "which keyword does
Builder.buildselect for a given preceding string".base/fixedgive the winningkeyword@index. A row only changes behaviour if the winning keyword differs (a differing index with the same keyword is inert —Builder.builduses the keyword'sfn, not its index; confirmed byte-identical output for every such row, bottom table).""(empty string)-1(no keyword)-1stringifyalways prefixes query text;buildhandles-1via theescapeIdentifiersfallback, unchanged"xyzzy "(no keyword at all)-1-1sql('column')identifier tests)"in "(keyword at index 0,^anchor)in@0in@0^alternation of the pattern, probed"in"(whole string is the keyword, both anchors)in@0in@0"select x from t where x in"($end-of-string delimiter)in@23in@23$branch with a second keyword present"...where x in "(single keyword, space delims)in@23in@23where parameters in(),dynamic in after insert"...where (x,x) in("(single keyword,(delim)in→($1,$2)in→($1,$2)inmeasured as the winning builder on both arms, so the control still controls this row; test passes on both armsin, same delims (" in "," in ")in@21in@21Last keyword used even with duplicate keywords; test 3 (control)in, same delims (" in("," in(")in@21in@21\1backreference was already correct for the same-delimiter case; measured byte-identical SQL on both arms (...in(select x from test where (x,x) in(($1,$2)))) and the test passes on both" in("then" in "select@26in@50" in "then" in"(ends string)select@27in@51inspacedin@51in@51" in "first, then" in(", then spaced" in "select(throws)in→($1,$2)(-delimited occurrencewhere age inin@29in@29where parameters in(); probed byte-identicalin, mixed delimsin@13in@26($1,$2); test 7 now runs a three-occurrence/three-delimiter query end-to-end (fails on base)"(in(in("(adjacent, overlapping delimiters)in@0in@0IN(thenINin@13in@13iflag retained); test 8 (fails on base)WHERE X INin@23in@23\nas delimiter ([\s]class)in@23in@23[\s]class end-to-end with a real newline before a repeated keyword (fails on base)\tas delimiterin@23in@23[\s]character class as row 18, which test V4 pins\r\nCRLF — issue porsager#701's verbatim templatein@44in@44\(alone (create table t)-1-1\(out-ranksin:delete from t where (a,b,c) in (\(@30\(@30c2fe67b's'\\(': selectbuilder, not the lookahead. Deliberately out of scope.where xin)-1-1where inside)-1-1returningtwice, mixed delimsreturning@25returning@25in, and it fails on base with a wrong answer (expected 3 != got undefined), not a throwvaluestwice, diff delims (values(1) values)values@13values@23($1,$2). Confirms the defect was generic across keywords and that correcting it is inert where the keyword already won.insertthenvalues(helper precedence)values@13values@13array insert,dynamic multi row insertselectthenasas@15as@15Supports multiple nested fragments with parametersupdatehelperupdate@0update@0values-CTE queryvalues@48values@48sql([])under a singleinin@29in@29dynamic in with empty array; probed → both emit(null)sql([])under a repeatedin(mixed delims)select→""in→(null)where x in )and the server rejects it:syntax error at or near ")". The falsy-but-valid empty collection under the fixed predicate still routes toin, which maps()→(null)sql\in(...`` interpolated into the outer template)select@26(throws)in→($1,$2)Builder.buildviastringifyValue→fragment→stringifyrather than the top-level template — a distinct production call pathOver the 32-row ledger: 5 rows change behaviour (10, 11, 12b, 31b, 32) — all five are the reported bug, across both orderings, the empty-collection case and the nested-fragment call path. 2 rows shift index only (14, 26); both verified byte-identical in emitted SQL and parameters. Every row that changes behaviour has a test that fails on base (tests 1, 2, 6, V2, V1); rows 9, 16, 18, 25 are additionally pinned by tests V3, 8, V4, 5. Byte-identical check, verbatim:
Non-boundary property — catastrophic backtracking. The lookahead now contains the keyword pattern twice rather than a backreference. Measured on a 60,000-character query string: base
0 ms, fixed0 ms(bothidx=60022). The pattern has no nested quantifier over an alternation, so no exponential blow-up is introduced.Suggested upstream PR title
Fix wrong helper selection when a keyword is followed by ( - fixes #701