Skip to content

feat(v17): build libpg-query@17 with the full API, like v18 - #176

Closed
pyramation wants to merge 4 commits into
mainfrom
feat/v17-full-api
Closed

pyramation wants to merge 4 commits into
mainfrom
feat/v17-full-api

Conversation

@pyramation

Copy link
Copy Markdown
Collaborator

Summary

Builds versions/17 the way versions/18 is built: one flag in x-publish, everything else is generated by copy:templates from templates/full/.

 "x-publish": {
   "libpgQueryRepo": "https://github.com/constructive-io/libpg_query.git",
   "libpgQueryTag": "17-constructive",
-  "libpgQueryCommit": "1ade880f…"
+  "libpgQueryCommit": "1ade880f…",
+  "fullApi": true
 }

libpg-query@17 therefore gains parsePlPgSQL, scan, fingerprint, normalize (+ sync variants). That replaces the retired @libpg-query/parser@17 (full/) build path, so v17 no longer needs a separate package or release branch. Adding exports makes this a minor release (17.8.0).

Stacked on #174. This branch includes #174's commits, so merge #174 first. The only commit of its own is feat(v17): …. It conflicts with #175 only in the generated versions/17/src/index.ts; whichever merges second fixes that by rerunning pnpm copy:templates.

What doesn't change

  • Same libpg_query source (our fork, 17-constructive @ 1ade880), same emsdk 6.0.3, same memory flags. No C, fork, proto or @pgsql/types changes (@pgsql/types stays ^17.6.2).
  • parse/parseSync still go through _wasm_parse_query_raw, so the output is the same. I compared npm [email protected] with this build on all 293 SQL files in pgsql-parser's corpus (deparse, deparse-depesz, postgres_regress, plpgsql_regress), split into 50,866 statements: 50,866 byte-identical results, including the 5,910 error results (message + sqlDetails).

Cost

  • WASM grows from 1,150,984 to 1,648,744 bytes (+~490 KB), because fingerprint/normalize/scan/plpgsql are now compiled in. This is the same trade-off v18 already made.

Tests

  • v18's fingerprint, normalize, plpgsql, scan tests are copied to versions/17/test and added to pnpm test. Local: 86/86 pass on a fresh pnpm run build.
  • Docs (root README, PUBLISH.md, v17/v18 READMEs) now say the full API is on PG 17+ and 13–16 are parse-only.

Link to Devin session: https://app.devin.ai/sessions/bcd8b2f791164247acb7a659b481efc2
Open in Devin Desktop: https://app.devin.ai/desktop/session/bcd8b2f791164247acb7a659b481efc2?variant=devin
Requested by: @pyramation

@devin-ai-integration

Copy link
Copy Markdown

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Review complete. 🟠 1 high · 🟡 4 medium

💬 Inline comments (5)

  • 🟠 Error detection by string prefix returns errors as results — index.ts:279
  • 🟡 Restore the dropped context field on parse errors — index.ts:214
  • 🟡 Escape all control characters in scan JSON output — wasm_wrapper.c:322
  • 🟡 Validate x-publish fields in fetch-protos — fetch-protos.js:74
  • 🟡 Restore exported SqlErrorFormatOptions and duck-typed guard — index.ts:45
🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 Fix pg17-to-latest mapping in version README (README.md:61) — versions/17/README.md:57 claims "Our latest is built with the 17-constructive branch" and the dist-tag table at line 64 routes PG 17 to the latest tag (versions/17/README.md:61-68), but the root README.md states latest defaults to pg18 (npm install libpg-query@pg18 # PostgreSQL 18 (latest)).

The change converts the version packages to exact-commit pinning (copy-templates now validates a 40-hex SHA and stamps it into x-publish), regenerates the six version Makefiles from Makefile.template with tag+commit cloning, and rebuilds the docker/emsdk toolchain pins. For PG17 it replaces the slim wrapper with the full API surface: parse, parsePlPgSQL, fingerprint, normalize, scan, and sync twins, backed by a rewritten wasm_wrapper.c that serializes scan tokens and parse errors to JSON.

Files Change
scripts/fetch-protos.js, scripts/copy-templates.js Fetch protos from the pinned commit; enforce and propagate commit pinning through templates
templates/Makefile.template, versions/{13..18}/Makefile Regenerate build files with commit-pinned clones, overridable WASM memory flags, and emsdk pin
versions/*/package.json, README.md, PUBLISH.md, REPO_NOTES.md, templates/README.md Document and configure per-version publish tags and full-API promotion for PG17+
versions/17/src/index.ts, versions/17/src/libpg-query.d.ts, versions/17/src/wasm_wrapper.c Rewrite the TS binding layer and C wrapper with error-to-JSON serialization and memory cleanup
versions/17/test/* Add fingerprint, normalize, PL/pgSQL, and scan test suites

Reviewed commit: 9e294a4

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR pins every version package to an exact upstream commit, regenerates all Makefiles from templates, and rewrites the PG17 bindings with a full parser API (fingerprint, normalize, scan, PL/pgSQL) plus a rewritten C WASM wrapper.

Key findings

  • 🟠 Error detection by string prefix returns errors as results — index.ts:279
  • 🟡 Restore the dropped context field on parse errors — index.ts:214
  • 🟡 Escape all control characters in scan JSON output — wasm_wrapper.c:322
  • 🟡 Validate x-publish fields in fetch-protos — fetch-protos.js:74
  • 🟡 Restore exported SqlErrorFormatOptions and duck-typed guard — index.ts:45

Comment thread versions/17/src/index.ts
Comment on lines +279 to +281
if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) {
throw new Error(resultStr);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 bug · high

Error detection by string prefix returns errors as results

fingerprint, normalize, scan and their Sync twins detect failures only by resultStr.startsWith('syntax error' | 'deparse error' | 'ERROR') (versions/17/src/index.ts:279). The C wrapper returns error strings that match none of these prefixes: validate_input failure returns "Invalid input: query cannot be null or empty" (versions/17/src/wasm_wrapper.c:376) and unpack/allocation failures return "Failed to unpack scan result" / "Memory allocation failed". Consequently fingerprint("") resolves successfully with the literal string "Invalid input: query cannot be null or empty" as the fingerprint — which callers building cache keys silently persist — and scanSync("") surfaces a cryptic JSON.parse SyntaxError instead of a query error.

📋 Prompt for AI Agents

In versions/17/src/index.ts (fingerprint at 271-290, normalize at 292-311, scan at 450-469, and fingerprintSync/normalizeSync/scanSync at 402-448/471-493; mirror in versions/18/src/index.ts and templates/full/index.ts), replace the resultStr.startsWith('syntax error') ... heuristic with a structural error check. Preferred: in versions/17/src/wasm_wrapper.c have wasm_fingerprint/wasm_normalize_query/wasm_scan return a distinguishable envelope (e.g. {"error":"..."}) on failure and in index.ts detect that envelope and throw. Minimal TypeScript-only alternative: also treat an empty result and results starting with 'Invalid input', 'Failed to unpack', or 'Memory allocation failed' as errors, and reject empty queries up front in these six wrappers, because fingerprint("") currently returns the C-side error message as a successful fingerprint.

Comment thread versions/17/src/index.ts
Comment on lines 214 to +232
if (errorPtr) {
// Read PgQueryError struct fields
// struct { char* message; char* funcname; char* filename; int lineno; int cursorpos; char* context; }
const messagePtr = wasmModule.getValue(errorPtr, 'i32'); // offset 0
const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32'); // offset 4
const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32'); // offset 8
const lineno = wasmModule.getValue(errorPtr + 12, 'i32'); // offset 12
const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32'); // offset 16
const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); // offset 20
// Read PgQueryError struct
const messagePtr = wasmModule.getValue(errorPtr, 'i32');
const funcnamePtr = wasmModule.getValue(errorPtr + 4, 'i32');
const filenamePtr = wasmModule.getValue(errorPtr + 8, 'i32');
const lineno = wasmModule.getValue(errorPtr + 12, 'i32');
const cursorpos = wasmModule.getValue(errorPtr + 16, 'i32');

const message = messagePtr ? wasmModule.UTF8ToString(messagePtr) : 'Unknown error';
const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : null;
const funcname = funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined;
const filename = filenamePtr ? wasmModule.UTF8ToString(filenamePtr) : undefined;

const errorDetails: SqlErrorDetails = {
message: message,
throw new SqlError(message, {
message,
cursorPosition: cursorpos > 0 ? cursorpos - 1 : 0, // Convert to 0-based
fileName: filename || undefined,
functionName: funcnamePtr ? wasmModule.UTF8ToString(funcnamePtr) : undefined,
lineNumber: lineno > 0 ? lineno : undefined,
context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined
};

throw new SqlError(message, errorDetails);
fileName: filename,
functionName: funcname,
lineNumber: lineno > 0 ? lineno : undefined
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Restore the dropped context field on parse errors

The rewrite of parse/parseSync stopped reading the context pointer at offset 20 of the PgQueryError struct and no longer sets context in the thrown SqlError (versions/17/src/index.ts:214-232). The C wrapper still populates PgQueryError.context (versions/17/src/wasm_wrapper.c:207-213) and the exported SqlErrorDetails interface still declares context?: string, so consumers relying on the surrounding SQL fragment Postgres reports silently receive undefined for every parse error.

📋 Prompt for AI Agents

In versions/17/src/index.ts, in the error branches of parse (lines 214-232) and parseSync (lines 342-360), re-add const contextPtr = wasmModule.getValue(errorPtr + 20, 'i32'); after the cursorpos read and include context: contextPtr ? wasmModule.UTF8ToString(contextPtr) : undefined in the SqlError details object. The struct is {message, funcname, filename, lineno, cursorpos, context}, so offset 20 holds the context char* on 4-byte-pointer wasm builds. Apply the same change in versions/18/src/index.ts and templates/full/index.ts.

Comment on lines +322 to +339
for (int j = 0; j < token_length; j++) {
char c = token_text[j];
if (c == '"' || c == '\\') {
escaped_text[escaped_pos++] = '\\';
escaped_text[escaped_pos++] = c;
} else if (c == '\n') {
escaped_text[escaped_pos++] = '\\';
escaped_text[escaped_pos++] = 'n';
} else if (c == '\r') {
escaped_text[escaped_pos++] = '\\';
escaped_text[escaped_pos++] = 'r';
} else if (c == '\t') {
escaped_text[escaped_pos++] = '\\';
escaped_text[escaped_pos++] = 't';
} else {
escaped_text[escaped_pos++] = c;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Escape all control characters in scan JSON output

The JSON escaping loop in build_scan_json only escapes ", \, , \r, and \t; every other C0 control character such as \b, \f, or \x01 is copied verbatim into the JSON string (versions/17/src/wasm_wrapper.c:322-339). Token text is attacker-controlled SQL fed to wasm_scan, and JSON.parse in scan/scanSync (versions/17/src/index.ts:462) rejects raw control characters in string literals. Any query containing, e.g., a form feed inside a dollar-quoted string or block comment makes scanSync throw SyntaxError: Bad control character in string literal instead of returning tokens.

📋 Prompt for AI Agents

In versions/17/src/wasm_wrapper.c, in the character-escaping loop of build_scan_json (lines 322-339, and the same code in templates/full/wasm_wrapper.c), after the existing cases for '"', '\', '\n', '\r', '\t' add a final branch for any character c < 0x20 that writes a six-character \u00XX escape (e.g. snprintf(&escaped_text[escaped_pos], 7, "\\u%04x", (unsigned char)c) and advance escaped_pos by 6). Since the escaped buffer is sized token_length * 2 + 1, raise it to token_length * 6 + 1 (and the matching estimated_size allowance) so worst-case escaping cannot overflow. This keeps scan/scanSync from failing JSON.parse on SQL containing control characters.

Comment thread scripts/fetch-protos.js
Comment on lines +74 to +79
// The same repo and commit the WASM is built from
const repoPath = new URL(libpgQueryRepo).pathname.replace(/^\/|\.git$/g, '');
const url = `https://raw.githubusercontent.com/${repoPath}/${libpgQueryCommit}/protobuf/pg_query.proto`;
const destPath = path.join(versionDir, 'pg_query.proto');

console.log(`Fetching protobuf for PostgreSQL ${pgVersion} with tag ${libpgQueryTag}...`);
console.log(`Fetching protobuf for PostgreSQL ${pgVersion} from ${repoPath} ${libpgQueryTag} @ ${libpgQueryCommit.slice(0, 7)}...`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 security · medium

Validate x-publish fields in fetch-protos

fetchProtos consumes libpgQueryRepo/libpgQueryCommit straight from each package's x-publish with no validation (scripts/fetch-protos.js:75-79), while copy-templates.js:39-41 enforces a 40-hex commit for the same data. new URL(libpgQueryRepo).pathname and libpgQueryCommit.slice also run outside the per-version try block, so a missing or malformed field throws a TypeError that escapes the loop and aborts the fetch for all remaining versions. Impact: a proto can be fetched from any repo or ref the config names — silently decoupling the shipped proto from the pinned WASM commit — and one bad package.json field crashes the whole script with an unhelpful stack instead of a per-version error.

📋 Prompt for AI Agents

In scripts/fetch-protos.js, mirror the validation in scripts/copy-templates.js lines 39-41: in getVersionMappings (lines 16-24) reject mappings whose libpgQueryCommit does not match /^[0-9a-f]{40}$/ or whose libpgQueryRepo is missing/not an https URL on the allowlisted hosts (github.com paths under pganalyze or constructive-io), throwing with the package path in the message. Additionally move the new URL(libpgQueryRepo) construction and libpgQueryCommit.slice(0, 7) logging (lines 75-79) inside the existing per-version try/catch (lines 81-89) so a single malformed version logs a failure and sets process.exitCode = 1 instead of aborting all remaining versions with a raw TypeError.

Comment thread versions/17/src/index.ts
Comment on lines +45 to 58
export function hasSqlDetails(error: unknown): error is SqlError {
return error instanceof SqlError && error.sqlDetails !== undefined;
}

// Format SQL error with visual position indicator
export function formatSqlError(
error: Error & { sqlDetails?: SqlErrorDetails },
error: SqlError,
query: string,
options: SqlErrorFormatOptions = {}
options: {
showPosition?: boolean;
showQuery?: boolean;
color?: boolean;
maxQueryLength?: number;
} = {}
): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Restore exported SqlErrorFormatOptions and duck-typed guard

The rewrite deletes the exported SqlErrorFormatOptions interface (previously the third parameter of formatSqlError) and changes hasSqlDetails from a duck-typed error is Error & { sqlDetails: SqlErrorDetails } guard to error is SqlError (versions/17/src/index.ts:45-58). Sibling version packages still export the interface, so TypeScript consumers importing it from v17 now fail to compile; and errors deserialized across a process boundary that lost their prototype silently fail the instanceof SqlError check, changing formatting behavior at runtime.

📋 Prompt for AI Agents

In versions/17/src/index.ts lines 45-58, restore backward-compatible signatures matching the other version packages: export function hasSqlDetails(error: unknown): error is Error & { sqlDetails: SqlErrorDetails } using a duck-typed check for a sqlDetails object, and re-export the SqlErrorFormatOptions interface with formatSqlError(error: Error & { sqlDetails?: SqlErrorDetails }, query: string, options: SqlErrorFormatOptions = {}). If the breaking change is intentional, gate it behind a package major bump and document it.

@pyramation pyramation closed this Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant