Skip to content

Changelog ​

What changes in each version of pgcast, newest first, from the repository's CHANGELOG.md. Follow the releases with the Atom feed.

All notable changes to pgcast are documented here. Format: Keep a Changelog. Versioning: SemVer.

pgcast is forked from Zapatos 6.6.1; upstream history lives in that repository.

Unreleased ​

Added ​

  • Page actions on the docs site, beside the title of each written page: "Copy page", and a menu to copy the page as Markdown, view it, or open a new chat in ChatGPT or Claude whose prompt names its Markdown's URL; each row of the menu has its icon and, under its name, what it does. Each page's Markdown is also served at /<page>.md (https://pgcast.dev/select.md), the same text as its part of llms-full.txt. Nothing outside the site is fetched until a chat's link is followed. Not on the home, the changelog or the playground.
  • A playground on the docs site, https://pgcast.dev/playground (decision 005): edit a query on the docs schema and see, as you type, the SQL pgcast compiles, with its values, and the type of its result, with hover types and errors from TypeScript 5.9.3 in Monaco. pgcast's own runtime runs the code in a worker of the page, against a pool that records each query, and nothing leaves the browser. It starts from the tutorial's first query; a phone gets that query read-only. Only /playground loads the editor, about 2.7 MB gzipped. The top bar's Changelog becomes Playground; the changelog is linked from the footer. monaco-editor 0.57.0 is a dev dependency of website/ alone: nothing changes in the published package.
  • The playground runs the code on Postgres in the page: Run, or Ctrl+Enter (⌘+Enter on a Mac), and never on its own, starts PGlite 0.5.13 (Postgres 18.6 in WebAssembly) on the docs schema in the code's worker, and shows under each query the rows it returns (the first 50, as JSON), its command, or its error with its SQLSTATE. Each Run starts on the docs schema as it ships, and stops 10 s after Postgres is up. PGlite is downloaded on the first Run only, about 5.3 MB gzipped. Its adapter is a pg.Pool of one connection with pg's parsers (int8 a string, bytea a Buffer, date local, point { x, y }), and sends values as pg sends them (its prepareValue, ported: an array as an array literal, a Date in local time, an object as JSON); each query is prepared, so a text of several statements rejects with 42601. npm run docs:examples checks it parses each table of the docs schema and takes a list of values as pg does, and runs every example on it too. @electric-sql/pglite is a dev dependency of website/ alone.
  • The playground's Share button, beside Run: it puts the code in the page's URL, #code/ and the code deflated (the browser's CompressionStream) and base64url-encoded, and copies the link. The fragment never reaches the server. Opening the link restores the code into the editor with its type, and runs nothing, not even the recorded SQL, until the reader presses Run or edits it. A damaged link, or one past 100 KB of code, opens the tutorial's first query with a notice; on a phone, the link's code shows read-only. The tutorial's first query makes a link of 283 characters, 5 KB of examples one of 1,554.
  • "Open in playground" on each cast panel of the docs pages, at the end of its code's label: a link to /playground#code/… that opens the panel's example, its imports included, which runs only on Run. The link is made at build, so the page runs no script for it, and the build decodes each one as the playground does: a panel without its link, or whose link opens other code, fails it. Its accessible name names the panel's section (Open in playground: Arrays, example 2). Not on the home. Paginate's four panels, whose code runs page 1 first, have one too: a comment above their query, // the playground gives the type of the next const, makes the playground give that query's type, as the panel does, not page 1's.
  • Icons on the docs site, one set drawn in website/.vitepress/theme/icons.ts (16px, a 2px stroke on a 24px grid, hidden from screen readers): a note's chip opens on its kind's icon (tip, info, warning, danger), the pager's Previous and Next have their arrows, the playground's Run and Share theirs.
  • The changelog on the docs site, https://pgcast.dev/changelog, with an Atom feed of the releases, https://pgcast.dev/changelog.xml: both built from CHANGELOG.md, so a change here reaches them with no other edit. The nav's Changelog and the pages' links to the changelog go there instead of GitHub; each version has an anchor (#6.6.2), and each of its sections too (#6.6.2-security).
  • SECURITY.md: supported versions, how to report a vulnerability privately, what to expect, scope and threat model. CONTRIBUTING.md: what pgcast accepts, local setup, commits and pull requests.
  • The upsertRequiresSameKeys config option (db.setConfig({ upsertRequiresSameKeys: true }), off by default, optional in Config): a multi-row upsert() without updateColumns, where a key missing from some row is still set from EXCLUDED, throws before any query ("rows have different keys: pass updateColumns, or give every row the same keys"), instead of overwriting the existing value with its DEFAULT. A key of updateValues or a column of an array or string conflictTarget does not count (new rows without their serial id, upserted on 'id', pass); with constraint(), every key counts. Rows with the same keys, one row, and any updateColumns compile as before.
  • The undefinedMeansMissing config option (db.setConfig({ undefinedMeansMissing: true }), off by default, optional in Config): an undefined value acts as a missing key. insert() sends DEFAULT for it (VALUES ($1, DEFAULT)); update() leaves its column out (SET ("name") = ROW($1)), and throws "nothing to update…" if no key is left; upsert() leaves its key out, so one row does not insert that column, nor update it unless updateColumns names it (then it is still set from EXCLUDED and an existing row takes the column DEFAULT, as for a missing key), and in several rows its cell takes DEFAULT and counts as missing for upsertRequiresSameKeys. An undefined in a Whereable or in updateValues is unchanged.
  • The transactionRequiresCommit config option (db.setConfig({ transactionRequiresCommit: true }), off by default, optional in Config): when the callback of transaction() catches a query's error and returns, Postgres answers the COMMIT with ROLLBACK and saves nothing; transaction() then rejects ("the transaction was rolled back, not committed: …"), without a retry nor a ROLLBACK of its own, and releases its client as before. The SQL and what the listeners get are unchanged; a ROLLBACK TO SAVEPOINT after the error lets the transaction commit.
  • The selectExactlyOneChecksForSecondRow config option (db.setConfig({ selectExactlyOneChecksForSecondRow: true }), off by default, optional in Config): a selectExactlyOne() run on its own asks for LIMIT 2 (… WHERE ("id" = $1) LIMIT $2, $2 being 2) and rejects with a NotExactlyOneError on a second row ("One result expected but more returned …"). Nested in another query, in a lateral or a sql template, it keeps LIMIT 1, where a second row would duplicate the parent row. It never warns and stays off in 7.0 (decision 003, R14). Off, the SQL and the result are Zapatos 6.6.1's.
  • The generator's typeEnumsAcrossSchemas option ("typeEnumsAcrossSchemas": true in zapatosconfig.json, off by default, optional in Config): a column whose type is an enum of another schema is typed by its labels, written out (status: ('archived' | 'draft' | 'published'), ('happy' | 'ok' | 'sad')[] for an array), looked up in the schema of its type rather than by name in the relation's. A domain over such an enum keeps its custom type, and its new placeholder gets the labels rather than any. It will be the default in 7.0 (decision 003, R40). Off, the generated files are Zapatos 6.6.1's, byte for byte.
  • The generator's enumOrder option ("enumOrder": "declared" in zapatosconfig.json, default "alphabetical", optional in Config): an enum's labels keep their declared order, by which Postgres sorts and compares its values (enumsortorder, a label added by ALTER TYPE … ADD VALUE … BEFORE included), rather than the alphabetical one: in its type, in its every.<name> tuple (['sad', 'ok', 'happy']), and, with typeEnumsAcrossSchemas, in the labels written out for an enum of another schema and the new placeholder of a domain over one. Another value stops the generator before any query: enumOrder must be 'alphabetical' (the default) or 'declared', not 'declard'. "declared" will be the default in 7.0 (decision 003, R50). With "alphabetical", the generated files do not change.
  • A documentation site in website/ (VitePress, decision 004), a package of its own: the theme of docs/design/art-direction.md (system, light and dark), the nav in four groups, local search, the home and Install pages; the other pages are marked "Coming". cd website && npm ci && npm run docs:build; CI builds it on a change to website/ or src/, and a dead link fails it. Nothing changes in the published package.
  • The docs site deployed to GitHub Pages, https://pgcast.dev/, on every push to master (.github/workflows/docs.yml, the official Pages actions pinned to commit SHAs); a PR only builds it.
  • The docs site at its own domain, https://pgcast.dev/; its first address, under github.io, redirects to it, path kept. A robots.txt at its root lets every crawler in, assistants' included, and names the sitemap.xml that the build now writes. llms.txt and llms-full.txt give the new URLs, and the link check reads https://pgcast.dev/… as the site.
  • The docs site's 404 page, served with status 404 at any address it does not have: a line saying the page is not there, a button that opens the search, and links to the four groups of the outline and the home. As before, its <head> has no canonical URL nor Open Graph.
  • Each page of the docs site carries, in its <head>, its own description (its lede as plain text, cut after the last whole sentence within 160 characters, unless its front matter sets description), a canonical URL, Open Graph and a Twitter card with one social image, and on the home a JSON-LD SoftwareSourceCode; the site has a favicon (:: cut out of a teal tile, light and dark), an Apple touch icon and a theme-color. Each page shows its "Last updated" date and sitemap.xml its lastmod, from git, so CI builds the docs from the whole history. npm run docs:images, in website/, renders the PNG and ICO from their SVG. The package's homepage is now https://pgcast.dev/.
  • The docs site's cast panel, on the home and Install pages: what you write, the SQL Postgres runs and its values, and the type TypeScript knows, all computed at build. Twoslash compiles each example against the built library and a zapatos/schema that pgcast generates from the docs schema (website/schema.sql, npm run docs:schema); a type error fails the build, in an example or in the docs schema itself (tsc runs first). The SQL is the example's own .compile(); the type is its name, then one level down. The docs build now needs the root built first.
  • The docs site writes llms.txt and llms-full.txt to its root at build (llmstxt.org), for assistants: an index of the written pages, then all of them as plain Markdown, each cast panel with its SQL, values and type as compiled at build. Install links both. Inline code keeps its text whatever its number of backticks (db.sql<s.films.SQL>`…`), and an in-page link becomes an absolute URL, as between pages. A code block nested in a list keeps its indent on every line, and a block meant to fail marks each line it fails on (// error TS2741).
  • CI's docs job, now with a Postgres service, regenerates the docs schema's types (website/zapatos/) from website/schema.sql and fails if they differ from the committed files.
  • The docs build fails when an export of zapatos/db, or an option of its Config or of the generator's, has no entry in a Reference page (a heading anchored {#name}) and is not in website/reference-pending.json, the names whose page is still "Coming"; a pending name that has its entry fails it too, so the list only shrinks.
  • A guide to writing a docs page, in website/README.md § Writing a page, linked from CONTRIBUTING.md: which of the four groups a page belongs to, the conventions (title and lede, notes and their chips, options tables, links, the film world), the reference check, what feeds llms.txt, screenshots for a PR, and a checklist. One template per group in website/templates/, not published.
  • The docs page Your first typed query (website/first-query.md), the tutorial after Install: configure and run the generator, then a first selectOne(), select() with columns and insert() on the docs schema, each in a cast panel, with the types they refuse. Install now stops after adding the package; its first query moved there. llms-full.txt now includes the cast panels whose example name has a hyphen, and the build fails on a panel it would leave out.
  • The docs site's reference of the writes, insert(), upsert(), update(), deletes() and truncate() (website/insert.md): each option with the SQL it adds and its effect on the type, the errors each throws, pgcast's changes from Zapatos 6.6.1, and the opt-ins upsertRequiresSameKeys and undefinedMeansMissing; a cast panel per shortcut.
  • The docs build fails on a link to a page or an anchor that does not exist (a Markdown link or a raw <a href>), and on a root link outside the site's base; a name still in website/reference-pending.json counts as an anchor of its page.
  • The docs build also fails on a URL of the site (https://pgcast.dev/…) whose page or anchor does not exist, in a link or in a fenced block, naming the page, and the line in a block: the rules file of Use an AI assistant, copied into projects, no longer breaks unseen when a heading is renamed.
  • CI's docs job runs every docs example against Postgres, on the docs schema (cd website && npm run docs:examples): a query that type-checks but that Postgres refuses fails it, naming the page and the line. A cast panel's example runs in a rolled-back transaction, an example of several queries on its own copy of the database; an example that shows an error states its SQLSTATE or name, and must reject with it. An example also fails if a promise it does not await rejects, if it leaves a pool client in use, or if it does not settle within 10 s. docs:schema and docs:examples drop the databases a stopped or crashed run left.
  • The docs build checks the SQL and the pgcast messages that the pages quote in their prose, tables and sql blocks, in every group (website/check-prose.mjs, no database): each is marked by an HTML comment, <!-- probe: … --> naming the code whose compiled SQL it must be in, or <!-- message --> for a string the built zapatos/db must hold; a mismatch, or a mark it cannot read, fails with the page and the line. It found two that had drifted, now fixed: selectOne() and selectExactlyOne() send their LIMIT as a parameter, its value 1, not LIMIT 1. The Config page's isIn() quote is rewritten to the convention, "id" IN ($1, $2).
  • The docs build type-checks the tutorial's examples joined, in page order, as the reader assembles src/films.ts: a name declared twice, a missing import or a block that shows an error the file would not give fails it, naming the page and the line, and so does a whole file shown under them that differs. A page opts in with a line <!-- joined: <file> --> under its last step.
  • The docs build fails on a machine's absolute path in a built file, hover types included (website/check-paths.mjs): the checkout's, or one under /home/, /Users/, /runner/ or a drive letter. The hover of enableCustomJSONParsingForLargeNumbers(), on https://pgcast.dev/runtime-config, gave the build machine's path to @types/pg; a hover now names a package's module as it is imported, typeof import("pg").
  • CI's typescript-7 job type-checks the fixture's generated schema (test/generated/) with TypeScript 7.0.2, as a consumer project on TypeScript 7 does, against the declarations of src/db that the pinned TypeScript 5.9.3 emits; no Postgres. The build and the other checks stay on 5.9.3. tsconfig.json drops baseUrl and test/generated/tsconfig.json makes its paths relative, which TypeScript 7 requires (TS5102, TS5090); dist/ and the declarations are unchanged.
  • ReturningTypeForTable and UpsertReturnableForTable exported from zapatos/db, with their Zapatos 6.6.1 names and parameters: one row of what insert(), update() and deletes() return, and of what upsert() returns, with $action unless reportAction: 'suppress'. A test can type a fake result from the schema. Type tests pin them, FullSelectReturnTypeForTable and RunResultForSQLFragment to what each shortcut's .run() gives; each has its Reference entry.
  • Dependabot version updates (.github/dependabot.yml), weekly: dev dependencies, minor and patch, in one PR; GitHub Actions in another. A minor or patch never changes dependencies or peerDependencies.

Changed ​

  • The docs site presents pgcast on its own, Zapatos credited in the footer: the home loses its "maintained fork" line, its second button is Playground and its fourth pillar Live playground, and its content is the page's one <main>; the site's description, its Open Graph, JSON-LD and llms.txt, and the AI assistant's rules file no longer call it a fork. Switch from Zapatos is the last of the How-to guides, and Install links it. The README's provenance moves to its Relationship with Zapatos.
  • The docs pages' titles are brief, the concept's name, in the nav, each page's heading, its <title>, llms.txt and the links that quote them: First query, Joins, Pagination, Batches, Retries, Custom types, Raw SQL, Errors, Testing, AI assistants; SQL template, Select, Writes, Conditions, Transactions, Configuration, Generator; The catalog, Versions, Security. The function names stay in each page's lede, so the search still finds them; the URLs and anchors are unchanged.
  • The docs home's install command has a tab for each package manager, npm, pnpm, yarn and bun, npm first, and the command in its own panel; Copy copies the one shown. The reader's pick is remembered across pages and visits (localStorage), and the code groups of package managers, on Install and Switch from Zapatos, open on it too.
  • The README and the docs install pgcast by its npm name, zapatos@npm:@pgcast/pgcast, and no longer say it is not on npm; the docs' Install and Switch from Zapatos give the command for npm, pnpm, yarn and bun. The yarn and bun commands take the major as a range (@pgcast/pgcast@^6), and the docs build fails on one that is not the major of package.json.
  • The JSDoc of constraint() and upsert(), and the Reference entry of constraint(), say that the type does not check that a constraint belongs to the upsert's table, which Postgres refuses (42704): a db.constraint('…') held in a variable takes any table's name, and db.constraint<'users'>('users_pkey') checks the name against that table. The type is Zapatos 6.6.1's, unchanged (R32).
  • The JSDoc of selectExactlyOne() and its Reference entry say that it checks for at least one row, not exactly one: LIMIT 1, then the first row. The behaviour is Zapatos 6.6.1's, unchanged.
  • A cast panel shows the type of its example's first const with no type written, the query's, so an example can type its input first: the tutorial's "Add a film" shows the inserted row again, films.JSONSelectable, not the films.Insertable it was given.
  • Links in the docs site keep their hash as written, so they reach a reference entry anchored with capitals or dots (/runtime-config#Config.upsertRequiresSameKeys); VitePress made it #config-upsertrequiressamekeys, which went nowhere.
  • The docs site in the four groups of Diátaxis (decision 004, amended): Tutorial, How-to guides, Reference and Explanation, each with a landing page that says what it is for and lists its pages, every planned page in its group ("Coming" until written). Install keeps its URL and opens the tutorial; the nav bar reads Tutorial · Guides · Reference, and the home's "read next" links the four groups.
  • README.md rewritten for pgcast: what it fixes and adds, an example with its SQL and type, what differs from Zapatos 6.6.1, the versions policy, the relationship with Zapatos.
  • The docs site and the README take their examples from pgcast's own world, a film's cast, instead of Zapatos's authors and books (decision 004, amended): the docs schema (website/schema.sql) holds studios, films, actors, roles, crew, cinemas, screenings, seat_bookings, ratings and tags, snake_case, with an array column, a nullable boolean and a domain, and invented seed rows from id 1000. The tests keep their fixture.
  • package.json: the author is the maintainer, Alexandre Demartin; George MacKerron, the original author of Zapatos, is listed under contributors. .github/FUNDING.yml lists both. The published files are unchanged.
  • The docs of strict() and toBuffer(), in their JSDoc and Reference entries, say that only null passes through, as only NULL does for a STRICT function in Postgres: undefined reaches the wrapped function, which handles it if it takes it and may throw otherwise, and the type refuses it unless the function takes it, with strictNullChecks on. The behaviour is Zapatos 6.6.1's, unchanged.
  • The Conditions page and the JSDoc of add() and subtract() show date arithmetic as a sql fragment value, as their type takes a number or a Date, not a Parameter (TS2345): db.sql`${db.self} + ${db.param(3, 'int4')}` adds three days to a date, an interval parameter a day to a timestamptz. On a date column, the type refuses a number (TS2322), which Postgres finds ambiguous (42725); a Date type-checks on date, timestamp and timestamptz columns, and Postgres refuses it. The behaviour is Zapatos 6.6.1's, unchanged (R33).
  • The Conditions page and the JSDoc of arrayContains(), arrayContainedIn() and arrayOverlaps() say that their type is on the element, so on an array column the type refuses { genres: arrayOverlaps(['romance']) } (TS2322), and show the two ways that compile: the column's type as T, its value an array of arrays (arrayOverlaps<string[]>([['romance', 'thriller']])), or a sql fragment value with a cast parameter, db.sql`${db.self} @> ${db.param(['drama'], 'text[]')}`, which also runs with castArrayParamsToJson on, where a condition's array gives 42883. Every type tried on the column broke code that compiles under Zapatos 6.6.1: a mock written to its signature, or the typed result of .map(arrayContains). The behaviour is Zapatos 6.6.1's, unchanged (R34).
  • The docs site's look, polished (art direction, which now describes the live site; the first prototype is removed). Headings at one weight, 600, sized in clearer steps; nav group labels and "On this page" in sentence case, not tracked capitals; inline code without its border; code comments upright; the search button reads "Search". The cast panel cuts no line: a long line wraps under its own text, 2ch further in, and the panel shows three columns from 1140px wide, what you write and the SQL with the type below from 900px, one column under that. The install command sits under the hero's two buttons as one quiet line with a copy icon. Each group's landing page lists its pages with their description under each title. The left nav and the home's sections are spaced on a 4px grid.
  • The docs site's left nav sets each section title apart from its pages: the headings' face in the text colour, on a band across the nav, its pages in grey. Each group's landing page gives its pages' titles as underlined links, as every other link in the docs.
  • The docs site's left nav folds: a chevron at the end of each group's heading folds or opens it, from the keyboard too. Tutorial and Reference start open, How-to guides and Explanation folded, and the group of the current page is always open. The headings lose their band and stand apart from their pages by size and colour alone; the pages sit a step further in, in grey, and the current one is a filled row in the text colour, no longer teal.
  • The docs site's search, nav and steps, after Cursor's docs: the search dialog is one panel near the top, each result its path of headings, its title and two lines of its text, the match underlined, the selected row filled as the nav's current page, and the keys as keycaps at its foot; the left nav is compact (rows of 32 and 28px that touch, 16px between groups); a numbered step's number (## 1. Set up the project) sits in a round marker. Still VitePress's local search, from the keyboard as before, and nothing sent anywhere.
  • The docs site serves its three fonts itself, from website/public/fonts/, each beside its SIL OFL licence, and preloads them: Google Fonts' stylesheet, the one resource that blocked the first paint, is gone, and no request leaves the site for a font. The files and the type are those Google Fonts served.
  • The docs pages Switch from Zapatos and The security model, rewritten for an easy read, every fact kept: an At a glance under the lede, the switch as numbered steps, what changes from Zapatos 6.6.1 in tables (your code · Zapatos 6.6.1 · pgcast · what to do) that stack on a phone, a diagram of a value travelling beside the SQL, and no paragraph over 60 words. The tutorial and the guides Join through a lateral, Paginate a list and Insert and upsert in batches follow: the first query's steps numbered, an At a glance on Paginate a list. Every docs page gets more even spacing: 48px before a section, 24px around code, tables, cast panels and notes. The rules for the next pages are in website/README.md § Easy reading and the art direction § Layout and § Diagrams. The explanation pages Why pgcast, How types are cast from the catalog and Versions, opt-ins and warnings follow, still mostly prose: long paragraphs split, a few enumerations as lists, wide tables that stack, and on the catalog page an At a glance and a diagram of the catalog read into zapatos/schema.d.ts. The reference pages of the sql template, select(), the writes and db.conditions follow, every entry and its anchor kept: long paragraphs split, a few enumerations as lists, and two tables of long cells that stack. So do Retry a transaction, Write raw SQL safely, Handle errors and Test code that uses pgcast: their long paragraphs split, an At a glance on Write raw SQL safely. The reference of transactions, run-time configuration and the generator follow with a light touch: long paragraphs split, wide tables that stack, and on the transactions page an At a glance and a diagram of a transaction retried on 40001 or 40P01.

Deprecated ​

  • A multi-row upsert() without updateColumns, where a key missing from some row is still set from EXCLUDED: it is inserted as DEFAULT, then overwrites the existing value on conflict. It still runs, with the same SQL, and emits a DeprecationWarning (PGCAST_DEP001) once per process. upsertRequiresSameKeys will be the default in 7.0.
  • An undefined value in insert(), update() or upsert() values, sent as NULL: a NOT NULL DEFAULT column fails, and update() erases the column instead of leaving it. It still runs, with the same SQL, and emits a DeprecationWarning (PGCAST_DEP002) once per process. undefinedMeansMissing will be the default in 7.0.
  • A transaction() whose COMMIT Postgres answers with ROLLBACK, after a query error the callback caught: it resolves with what the callback returned, though nothing was saved, and a 40001 caught this way is never retried. It still does, and emits a DeprecationWarning (PGCAST_DEP003) once per process, only when that happens. transactionRequiresCommit will be the default in 7.0.
  • A column whose type is an enum of another schema, typed as an any custom type (c.PgStatus), or as the enum of the same name in the relation's schema, whose labels are not its own. The generator still writes it so, and notes it through warningListener once per run, naming each such column and its enum (public.products.status (extra.status)). typeEnumsAcrossSchemas will be the default in 7.0.
  • Enum labels written in alphabetical order, in the enum's type and its every.<name> tuple (['happy', 'ok', 'sad'] for ENUM ('sad', 'ok', 'happy')), not in the order Postgres sorts and compares them by. The generator still writes them so, and notes it through warningListener once per run, naming each enum of the generated schemas whose declared order differs (public.mood). enumOrder: 'declared' will be the default in 7.0.

Fixed ​

  • The generator warns, through warningListener, about each name of an include list that its schema doesn't have, which generates nothing: a typo, a case that differs (Films), a relation of another schema, or a table or view the database role has no privilege on, which the generator does not see. The warning names the entry and its schema, and says what to check; a schema that does not exist warns for each name it lists. "*" and a rule whose exclude is "*" never warn. Zapatos 6.6.1 ignored such a name silently. Nothing generated changes (R51).
  • The generator warns, through warningListener, when two custom types share a placeholder name: a domain notes in two schemas, or two names that customTypesTransform makes one (seat-label and seat_label under PgMy_type). They share one type and one placeholder file, and the last one read sets its base type; the warning names the types, "archive"."notes" and "public"."notes", the placeholder file, and what to do: rename one of them in the database, or, where their names differ, give customTypesTransform a function. Nothing generated changes: the placeholders keep their names, which edited files depend on (R42).
  • A Parameter as a Whereable value, or in vals([…]) (so isIn and isNotIn), is compiled as it is, with its cast: { name: param('Ada', 'text') } gives ("name" = CAST($1 AS "text")) with 'Ada'. Zapatos 6.6.1 wrapped it in a second parameter, sent as JSON ('{"value":"Ada","cast":"text"}'). Default as a Whereable value (or in isIn) throws: "db.Default has no meaning in a Whereable (a filter): leave the key out, or compare with a value"; in vals([…]) elsewhere it gives DEFAULT, as in vals({…}), except inside a fragment value of vals({…}) (below). Results that change:
    • isNotIn([param(x)]) and not({ col: param(x) }) matched every row; they now leave out x. So deletes(table, { col: isNotIn([param(x)]) }) deleted every row, and now keeps those equal to x.
    • deletes(), update() and select() / count() with a Parameter value on a column whose type accepts the JSON text as input (text, varchar, char, name, bytea) or jsonb found nothing; they now reach their rows, and write. isIn([param(x), y]) found only y.
    • In upsert() updateValues, a plain object is a Whereable of the existing row, so the same goes there. This revises the 6.6.2 line (#80) that compiles such a value "as before, byte for byte": the SQL text still is, the values and the rows written are not. upsert('flags', [{ k: 'a' }, { k: 'b' }], 'k', { updateValues: { admin: { 'flags.role': param('admin') } } }), where only a has the role admin, stored false on both rows; it now stores true for a. With isNotIn([param('guest')]), where only b has the role guest, it stored true on both, and now on a only. With Default, it now throws.
    • A hand-written sql`INSERT … VALUES (${vals([…, param(x)])})` stored '{"value":…}', and vals([…, Default]) stored 'Symbol(DEFAULT)' in a column whose type accepts it as input (text, varchar, name, citext, bytea, tsvector, xml); they now store x and the column default.
    • A Parameter on an int, enum, array or other column failed (22P02; 22007 on date and time types; 42601 on tsvector and tsquery), and Default in vals([…]) outside a filter failed on a column that refuses 'Symbol(DEFAULT)' as input; they now match, or store.
    • vals([Default]) inside a fragment value of vals({…}) (insert('memberships', { …, role: sql`${vals([Default])}` })) stored 'Symbol(DEFAULT)' where the column accepts it as input; it now throws, as in a filter: such a fragment is compiled with its key as the current column, as a Whereable value is.
    • { col: param(x, true) } on a jsonb column found nothing; it now fails as jsonb = json (42883), as sql`${self} = ${param(x, true)}` does.
    • Default in a filter found nothing (text) or failed with 22P02; it now throws while compiling, so .run() rejects with a plain Error, not a DatabaseError.
    • Unchanged: a raw() value (#101; refused since #108), an array that holds a Parameter ({ tags: [param(x)] }); param(param(x)) stays one parameter, its value the inner Parameter as JSON (6.6.1 nested it once more).
  • not() puts its condition in parentheses: not(sql`a OR b`) compiles to (NOT (a OR b)); it was (NOT a OR b), which Postgres reads (NOT a) OR b. Only a condition with a bare AND or OR at its top level now matches other rows: a sql fragment such as sql`${self} = 1 OR ${self} = 2` (or an array of fragments), alone, inside and() / or() or under another not(). Any other condition (eq, isNull, isIn, between, like, and(), or(), a Parameter, a Whereable) gains one pair of parentheses in its text and matches the same rows.
  • update() with an Updatable that has no keys (update('authors', {}, …)) throws as it builds the query, before anything reaches Postgres: "nothing to update: an Updatable needs at least one column". It compiled to SET () = ROW(), which Postgres refused (42601), so no call that ran stops; the error now comes from update() itself, not from .run(). A key whose value is undefined still counts, and sets the column to NULL as before.
  • Default and self given where they have no meaning throw while compiling, so .run() rejects with a plain Error, not a DatabaseError, before any query; the type already refused each. Zapatos 6.6.1 sent them as the strings 'Symbol(DEFAULT)' and 'Symbol(self)': no row on a text column, every row with ne() or isNotIn() (so deletes() deleted them all), 22P02 on the others. Each form that now throws:
    • Default or self as the value of eq(), ne(), gt(), gte(), lt(), lte(), after(), before(), isDistinctFrom(), isNotDistinctFrom(), either bound of the four between(), the ten like() / similarTo() / reMatch() conditions, arrayContains(), arrayContainedIn(), arrayOverlaps(), add() and subtract(): "db.Default has no meaning as the value of eq(), add() and the other conditions: compare with a value…", or "db.self … it is already the column on the left…". Calling the condition does not throw; compiling it does. So update(table, { n: add(Default) }, …) throws instead of failing with 22P02, and its message gives the fix: { n: db.Default }.
    • self as a Whereable value ({ name: self }), in isIn() / isNotIn(), or in vals([…]) within a filter or a fragment value of vals({…}) (insert('users', { …, tags: sql`ARRAY[${vals([self])}]::text[]` }) stored {Symbol(self)}; it now throws, in update() too): "db.self has no meaning as a value in a Whereable (a filter), nor in isIn(): compare with a value, or use it in a sql fragment value…".
    • The same values wherever a Whereable is compiled: the where of select(), count(), update(), deletes(), a lateral subquery (lateral: { n: count('books', { authorId: self }) } failed with 22P02), and a plain object in upsert() updateValues ({ settings: { theme: self } } failed with 42703).
    • Unchanged: self in a fragment ({ n: sql`${self} > 0` }), isIn([Default]) (it threw already), and outside a filter param(Default), param(self), vals([self]) and vals({ a: self }), still sent as strings. A condition directly in upsert() updateValues ({ n: add(Default) }) throws "The 'self' column alias has no meaning here", as before.
  • parent() in vals([…]), so in isIn() and isNotIn(), compiles to the parent column, as a Whereable value, eq() and a sql template already do: in a lateral subquery of authors, { authorId: isIn([parent('id')]) } gives ("authorId" IN ("authors"."id")); without a column, parent() takes the column of the filter; with no filter, it throws, as sql`${parent()}` does (R58). Outside a nested query it throws "The 'parent' table alias has no meaning here", so .run() rejects with a plain Error before any query. Zapatos 6.6.1 sent it as one parameter, as JSON ('{"value":"id"}'). Results that change:
    • In a lateral subquery, isIn([parent(c)]), isIn([parent(c), x]), isIn([parent()]) and isNotIn([parent(c)]) failed with 22P02 on an int column; on a column whose type accepts it as input (text, jsonb…), isIn found nothing and isNotIn matched every row ({ settings: isIn([parent('settings')]) } on jsonb). They now compare with the parent column, in nested laterals (the nearest parent), with an alias and in a passthrough lateral. The same goes for vals([parent(c)]) written by hand in any fragment of the subquery: a fragment value ({ authorId: sql`${self} IN (${vals([parent('id')])})` }), an extras fragment (extras: { a: sql`ARRAY[${vals([parent('display_name')])}]` } returned ['{"value":"display_name"}'], and now the parent's value) or a sql lateral subquery.
    • Outside a nested query, they now throw where select() and count() found nothing on a column whose type accepts it as input (text, jsonb…), or every row with isNotIn, where deletes(table, { title: isNotIn([parent('title')]) }) deleted every row and update() with isNotIn updated them all, and where an int column failed with 22P02.
    • Writes, which never sit in a nested query, now throw too where they stored '{"value":"c"}' in a column whose type accepts it as input (text, jsonb, an array element…): a hand-written sql`INSERT … VALUES (${vals([…, parent(c)])})` or ARRAY[${vals([parent('t')])}] ({"{\"value\":\"t\"}"} in a text[]), a fragment value of insert(), of update() or in the inserted values of upsert() ({ title: sql`${vals([parent('name')])}` }; {"value": "x"} in a jsonb column), and vals([parent(c)]) in upsert() updateValues, as it is or in a fragment.
    • Unchanged: parent() in a nested array (isIn([[parent('id')]]), 22P02) or in an array value ({ title: [parent('name')] }), as an insert() value ({ title: parent('name') }, sent as JSON, #112, until it throws, below), a fragment in isIn (isIn([sql`${parent('id')}`]), already the column), eq(parent('id')) and a parent() value.
  • raw(), cols(x) and vals(x) given as a filter value throw while compiling, so .run() rejects with a plain Error, not a DatabaseError, before any query; the type refuses them wherever a Parameter<T> stands. Zapatos 6.6.1 sent them as one parameter, as JSON: { name: raw("'Ada'") } compared with '{"value":"\'Ada\'"}', so no row on text and jsonb, every row with ne() or isNotIn() (deletes(table, { role: isNotIn([raw("'owner'")]) }) deleted them all), 22P02 elsewhere. raw() is not compiled as SQL there: raw(input) was a parameter by accident, and would become an injection. Migration: a sql fragment value, { name: sql`${self} = ${raw("'Ada'")}` }; or the value itself, or param(x).
    • They throw as a Whereable value ({ name: cols('Ada') }, { id: vals(1) }), in isIn() / isNotIn(), and in vals([…]) within a filter or a fragment value of vals({…}) (insert('users', { …, name: sql`${vals([raw("'Cy'")])}` }) stored {"value":"'Cy'"}): "db.raw(), db.cols() and db.vals() have no meaning as a value in a Whereable (a filter), nor in isIn(): to compare with SQL, use a sql fragment value, where db.self stands for the column; to compare with a value, give the value itself, or db.param()." So wherever a Whereable is compiled: the where of select(), count(), update(), deletes(), and(), or(), not(), a lateral subquery, and a plain object in upsert() updateValues ({ settings: { theme: raw("'x'") } } failed with 42703).
    • They throw as the value of eq(), ne(), gt(), gte(), lt(), lte(), after(), before(), isDistinctFrom(), isNotDistinctFrom(), either bound of the four between(), the ten like() / similarTo() / reMatch() conditions, arrayContains(), arrayContainedIn(), arrayOverlaps(), add() and subtract() (update('accounts', { n: add(raw('1')) }, …) failed with 22P02): "db.raw(), db.cols() and db.vals() have no meaning as the value of eq(), add() and the other conditions: for SQL, write a sql fragment with db.self in place of the condition; for a value, give the value itself, or db.param()."
    • They no longer type-check where the type took them as a Parameter<T>, which is structural ({ value: T }): raw() on a text or jsonb column, cols(x) and vals(x) on any column with x of its type, as a Whereable value, in isIn(), isNotIn(), eq() and the other conditions; and as an insert(), update() or upsert() value, where they are still sent as JSON at run time ({"value":"'Cy'"} stored in a text column, 22P02 on text[]; #132). Parameter, DangerousRawString, ColumnNames and ColumnValues each get a type-only member for this; no JS changes. So DangerousRawString, ColumnNames and ColumnValues no longer match a plain { value } nor each other; Parameter still does: const r: db.DangerousRawString = { value: 'x' } (TS2741) and const n: db.ColumnNames<string[]> = db.vals(['a']) (TS2322) type-checked, and now fail.
    • Nor where it took cols(x) and vals(x) as a ParentColumn, which is all-optional ({ value?: Column }): with x a column name or undefined, as a Whereable value ({ name: vals(undefined) }), in eq() ({ id: eq(cols('id')) }), isIn() / isNotIn() (isIn([vals('email')])) and the other conditions, a lateral subquery, and as the argument of arrayContains(), arrayContainedIn() and arrayOverlaps(); each threw at run time. parent() and param(x) still fit; no emitted JS changes.
    • In upsert() updateValues, compiled as in a sql template, they still type-check where Zapatos 6.6.1 took them as the column's Parameter<V>: raw() where V takes a string (text, jsonb), cols(x) and vals(x) with x of the column's value type V ({ manager_id: vals('nonsense') } is refused, as before). A plain sql`…` fragment on a NOT NULL column ({ display_name: sql`upper(EXCLUDED.${'display_name'})` }) now type-checks there too: Zapatos 6.6.1 inferred its result type with null and refused it. cols(x) and vals(x) of any value still type-check in a sql template.
    • Unchanged: a plain { value } object (a value, sent as JSON), raw() in a fragment, vals([raw(x)]) outside a filter (sent as JSON), a wrapper nested in an array or object value ({ tags: [raw(x)] }, which the type already refused), and the SQL of every call that compiled.
  • parent(), raw(), cols(x) and vals(x) as an insert(), upsert() or update() value throw while compiling, so .run() rejects with a plain Error before any query. Zapatos 6.6.1 sent each as one parameter, as JSON: a text column stored {"value":"upper('Ada')"} for { name: raw("upper('Ada')") }, {"value":"id"} for parent('id') and {} for parent(), a jsonb column {"value": …}, a text[] column [] for parent(), and upsert() set it on the existing row from EXCLUDED; 22P02 on int, an enum or a composite.
    • The check sits in vals({…}), which compiles the writes' values, so a sql template of your own throws too: sql`INSERT … VALUES (${vals({ name: raw('now()') })})`. raw(), cols() and vals() throw "db.raw(), db.cols() and db.vals() have no meaning as a value in vals({…})…", in a nested query or not; raw() is never compiled as SQL there, which would make a raw(input) an injection.
    • parent() in vals({…}) compiles as in vals([…]): the parent column in a nested query, without a column the column of its key (sql`SELECT ${vals({ name: parent() })}`, in a lateral of authors, gives "authors"."name"); outside one, so in every write, it throws "The 'parent' table alias has no meaning here".
    • Breaking where the type took them: on a column typed any (a zapatos/custom placeholder, a composite or xml), and in vals({…}) of a sql template, where 6.6.1 stored the JSON text; the type refuses them on every other column of an Insertable or Updatable, as before.
    • Unchanged: a wrapper nested in an array or a jsonb object ({ tags: [raw(x)] }, still JSON, which the type refuses), upsert() updateValues (where they are SQL), self in vals({…}) (sent as a string) and vals([raw(x)]) outside a filter (sent as JSON), and the SQL of every call that compiled.
  • The docs build's checks of the quoted SQL and messages and of the reference close a fenced block only on its marker alone, as CommonMark and VitePress do: a ```ts line inside a ```md block ended it, so the rest of the page went unchecked, and a failing mark passed. The four checks that read fences, the links' too, now share one rule (website/.vitepress/fences.ts), and each checks itself on such a block before the pages.
  • A built-in object interpolated as a Whereable throws while compiling, so .run() rejects with a plain Error before any query: sql`DELETE FROM ${'films'} WHERE ${new Date()}` compiled to DELETE FROM "films" WHERE TRUE and deleted every row (#170).
    • Refused: Date, Map, Set, WeakMap, WeakSet, RegExp, Promise, Error and its native subtypes, ArrayBuffer, a typed array, Buffer, DataView, and the boxes Number, String and Boolean, even with own keys added (Object.assign(new Date(0), { id: 1 }), ("id" = $1) in Zapatos 6.6.1), wherever a Whereable is compiled: a sql template, and() / or() / not(), the where of select(), count(), update(), deletes(), a lateral subquery. Zapatos 6.6.1 compiled each to its own enumerable keys: none (TRUE), or indexes for a typed array (("0" = $1)).
    • The error says what to do: give a value as db.param(…), a filter as a plain object, and await a Promise first.
    • The type refused most of them (TS2345), not one that shares a member with a column: deletes('actors', new Error('x')) type-checks where actors has a name column, and now throws.
    • Unchanged: a plain object, an object with a null prototype, a class instance, even of a class that extends a built-in, a view included (class Frames extends Uint8Array {}: TRUE when it has no own enumerable key, ("0" = $1 …) for its indexes), a built-in of another realm (a Date from vm.runInNewContext(): TRUE), a built-in as a Whereable value ({ released: new Date() } is $1), the wrappers, arrays, the global object's error.
  • setConfig() sets a key that has a default back to it when given as undefined (or null, from JavaScript), so getConfig() gives the default, as its type Config says. Zapatos 6.6.1 kept the undefined: after setConfig({ transactionRetryDelay: undefined }), which compiles, every transaction() on a pool rejected with "TypeError: Cannot destructure property 'minMs' …" and never released the client it had checked out, until the pool was exhausted; after transactionAttemptsMax: undefined, it gave up after one attempt. transaction() also releases its client now if reading the configuration throws. A listener has no default: undefined still removes it.
  • getConfig() and setConfig() return a copy of the configuration, transactionRetryDelay included: changing what they return no longer changes the configuration. Zapatos 6.6.1 shared transactionRetryDelay from getConfig() (getConfig().transactionRetryDelay.minMs = 1 set it), and setConfig() returned the configuration itself. The listeners are the functions you set, and setConfig() still keeps the transactionRetryDelay object it is given.
  • and() with no argument compiles to TRUE, and or() to FALSE: every row and no row, as a Whereable {} is TRUE. Zapatos 6.6.1 wrote (), which Postgres refuses (42601), alone, as a Whereable value, under not() or inside another and() / or(). With one condition or more, only the parentheses of the next line change.
    • Every row: update() and deletes() given and(...filters) or not(or(...ids)) with an empty list now reach every row (WHERE TRUE, WHERE (NOT (FALSE))), where Zapatos 6.6.1 threw (42601) before any row changed. Keep the length check where an empty list must not reach every row.
  • transaction() given a pg.native.Client, with pg 7 and pg-native, runs on that client and leaves it connected, as for a pg.Client. Zapatos 6.6.1 took it for a pool and called its connect(): the call rejected, with "Client has already been connected" for a connected client, or with a TypeError for one not yet connected. With pg 8, a native client was already treated as a client: unchanged.
  • and() puts each operand in its own parentheses: and(sql`"id" = 1000 OR "id" = 1001`, { studio_id: 1001 }) compiles to (("id" = 1000 OR "id" = 1001) AND (("studio_id" = $1))). Zapatos 6.6.1 wrote ("id" = 1000 OR "id" = 1001 AND ("studio_id" = $1)), which Postgres reads "id" = 1000 OR ("id" = 1001 AND …): film 1000 too, whatever its studio. Calls that change:
    • Other rows, or another value: an and() of two operands or more, one of which has a bare OR at its top level, a sql fragment such as sql`${self} = 1 OR ${self} = 2` (from JavaScript, also a raw() or an array of fragments, which the type refuses). Wherever the and() stands: first or last operand, a Whereable value, under or(), not() or another and(); the where of select(), count(), update() and deletes(), which now write or delete other rows; a lateral beside parent(), a having; and as a value, not a filter: extras, groupBy, distinct, order, an insert(), update() or upsert() value (updateValues included), a sql template.
    • An operand that is not a whole expression on its own, its SQL ending in the next one: now an error (42601 from Postgres) when the split falls in the grammar, and(sql`${'release_year'} BETWEEN 1970`, sql`2000`); another value, with no error, when it falls inside a quoted literal: and(sql`${'title'} = 'x`, sql`y'`) compares "title" with 'x) AND (y', where it was 'x AND y'.
    • Parentheses only, the same rows and values: every other and(). One operand, and(sql`a OR b`) giving ((a OR b)); operands with no bare OR at their top level: the conditions (eq(), isNull, isIn(), between(), like(), arrayContains()…), and(), or(), not(), a Whereable, a fragment with AND or NOT there, or its OR in parentheses, a CASE, a function call or a subquery. In { id: and(gt(1), lt(5)) }, the and() gives (("id" > $1) AND ("id" < $2)), where it was ("id" > $1 AND "id" < $2).
    • Unchanged: or(). AND binds tighter than OR, so no operand reaches across an OR.
  • A string cast of param() with no " is split on ., each part quoted, and array bounds at its end ([], [n]) follow the quotes: param(x, 'public.mood') gives CAST($1 AS "public"."mood") and param(x, 'int4[]') gives CAST($1 AS "int4"[]). Both quoted the whole name, a type that does not exist (42704). A cast already quoted is written as it is ('"public"."mood"'), and one that is well-formed once quoted as before ('pg_catalog"."int4'); any other cast stays one name, " doubled.
    • Changed: param(x, 'a.b') reached a type whose own name holds a . (CREATE TYPE "a.b"); it is now type b in schema a, and that type is param(x, '"a.b"').
    • Changed: param(x, 'tag[]') reached a type whose own name ends with [] or [n] (CREATE DOMAIN "tag[]"); it is now an array of tag, with no error when both exist, and that type is param(x, '"tag[]"'). 'public"."tag[]' moves the same way.
    • Changed from pgcast 6.6.2 (R3): param(x, '"q"') was CAST($1 AS """q"""), the type "q"; it is now CAST($1 AS "q"), the type q.
    • Unchanged: each part stays quoted, so a name only the SQL grammar knows ('int', 'double precision', 'varchar(10)') is still no type (42704): give the type's own name ('int4', 'float8'), or cast in sql.
  • A string cast of param() now applies under castArrayParamsToJson and castObjectParamsToJson, where Zapatos 6.6.1 replaced it with CAST($1 AS "json"): the parameter has the cast's type.
    • Value sent: under castArrayParamsToJson, an array whose cast ends with [] or [n] goes as an array, param([1, 2], 'int4[]') giving CAST($1 AS "int4"[]) with [1, 2]. Any other array stays JSON text, '_int4' and a domain over an array included: param([1, 2], 'jsonb') gives CAST($1 AS "jsonb") with '[1,2]'. Under castObjectParamsToJson, a plain object stays JSON text, whatever its cast.
    • Fixed: =, @> and the writes on an int4[], text[] or jsonb[] column with a cast ending with [], and = and @> on a jsonb column with 'jsonb', now run (42883 or 42804 before).
    • Unchanged: true, false, no cast, the array conditions given a plain array, an array under castObjectParamsToJson alone and a plain object under castArrayParamsToJson alone.
    • Changed: a cast that is not the target's type fails or converts, and one that names no type fails. Each call below ran under 6.6.1:
      • Into a json or jsonb column, or a domain over one, any cast but json, jsonb or such a domain is refused (42804): param(arr, 'int4[]'), param([1, 2], 'text'), param(obj, 'text'), param({ a: [1] }, 'jsonb[]'). Write param(x, 'jsonb'), or no cast.
      • Into a text column, the value is the cast type's text: param(arr, 'int4[]') stores {1,2} where it stored [1,2], param(x, 'jsonb') jsonb's spacing ([1, 2]), param(arr, 'bytea') \x5b312c325d. Into a json column, param(x, 'jsonb') stores jsonb's spacing too. Write param(x, 'json'), or no cast.
      • A bare SELECT returns the cast's type, not json: sql`SELECT ${param([1, 2], 'text[]')} AS v` gives ['1', '2'] where it gave [1, 2], and with 'text' the string '[1,2]'.
      • A cast whose type refuses the value fails wherever it stands (22P02, or 22007 for a date): JSON text is no array literal for param(arr, '_int4'), a domain over an array or param({ a: [1] }, 'jsonb[]'), and no number for param(arr, 'int4'); param(['a'], 'int4[]') has an item that is no int4. '_int4' failed on 6.6.1 only into an int4[] column (42804, 42883), and ran into json, jsonb, text and a SELECT: write param(arr, 'int4[]').
      • A cast that names no type fails: param(arr, '') and param(arr, '[]') (42601), and 'int4 []', the type "int4 " (42704).
  • count(), sum(), avg(), min() and max() throw while compiling on three options that Zapatos 6.6.1 compiled to SQL Postgres refuses, so .run() rejects with a plain Error, not a DatabaseError, before any query; .compile() throws it. Building the query does not throw, so a .catch() attached to .run() gets the error, and a query that nests one, in a lateral or extras, rejects too. The type is unchanged.
    • sum(), avg(), min() or max() without columns: 6.6.1 sent sum(*) (42883). The error is "sum() needs one column to aggregate: pass columns: ['column']".
    • columns with several columns, or none: 6.6.1 sent count("id", "mood") (42883) or count() (42809). The error is "count() takes one column in columns, not 2 (leave columns out for count(*))".
    • A single query as lateral: 6.6.1 sent SELECT AS result (42601). The error is "count() cannot pass a lateral query through: nest it in the lateral of a select() instead".
    • Changed: an aggregate of your own that takes another number of arguments under a built-in name ran. After CREATE AGGREGATE max(int4, int4) (…), max('ratings', db.all, { columns: ['stars', 'votes'] }) sent max("stars", "votes"); it now rejects. select('ratings', db.all, { columns: ['stars', 'votes'] }, SelectResultMode.Numeric, 'max') sends the same SQL.
    • Changed: a refused aggregate marked noop resolved to its noopResult; it now rejects, as run() compiles the query before it reads noop.
    • Unchanged: every other call that ran, a lateral map included, and select() in SelectResultMode.Numeric, which keeps several columns for an aggregate of several arguments (corr("rating", "minutes")).
  • The generated UniqueIndex of a table, so UniqueIndexForTable<T> and the names constraint() takes, lists only that table's own unique indexes. Zapatos 6.6.1 matched an index on its name alone, across schemas: a table with a plain index named like another schema's unique index got that name too, and Postgres refuses it in ON CONSTRAINT (42704). The name now fails to compile on that table (TS2345, or TS2322 for a value typed with it); a name that unique indexes of two schemas share stays on each table. R41.
    • Changed: given ratings with a plain index film_stars_key, and archive.ratings whose UNIQUE constraint has that name, db.upsert('archive.ratings', …, db.constraint<'ratings'>('film_stars_key')) compiled and ran, two type errors cancelling out. Name the table that has the constraint: db.constraint<'archive.ratings'>('film_stars_key'), or leave the table out inside the call.
    • Changed: code that lists every unique index of ratings, as typed, had to name film_stars_key under 6.6.1, and now fails on it: that key in a Record<s.UniqueIndexForTable<'ratings'>, string> (or over s.ratings.UniqueIndex), with satisfies too (TS2353), case 'film_stars_key' in a switch over one (TS2678), k === 'film_stars_key' (TS2367). Drop that key or case: ratings_pkey is left.
  • A multi-line COMMENT ON COLUMN puts each of its lines behind * in the generated JSDoc, whether it breaks them with \n, \r\n or \r. Zapatos 6.6.1 prefixed the first line only: in the editor's hover, a line after the first lost its indentation, and a * it began with. The first line and the types do not change. A comment holding */ is written as before, until #48 escapes it: its */ ends the JSDoc, and a * on the next line would break an output that compiles. R48.
  • The generated JSDoc of a column of a view, a foreign table or a partitioned table carries its COMMENT ON COLUMN, as a table's does; Zapatos 6.6.1 read these comments on tables and materialized views only. Each relation shows its own: Postgres copies no comment to a partition, an inheriting table or a view, and the generator adds none. The types do not change, nor anything with schemaJSDoc: false. On these three kinds, a comment holding */ is still left out until #48 escapes it: written, it would end the JSDoc, and could break an output that parses today. R49.
  • A readonly array as columns or returning, ['id', 'title'] as const or a readonly ColumnForTable<T>[], types the result as a mutable array does: the rows of those columns, in select(), selectOne(), selectExactlyOne(), insert(), upsert() (with $action), update(), deletes() and a lateral. With strictNullChecks, Zapatos 6.6.1 typed these rows as never (selectOne() as undefined), which any type accepted and whose properties could not be read. JSONOnlyColsForTable takes a readonly tuple, where 6.6.1 refused it (TS2344); FullSelectReturnTypeForTable, which gave never for one, ReturningTypeForTable and UpsertReturnableForTable give its rows. The SQL does not change.
    • Changed, with strictNullChecks: a type that the query does not return no longer compiles. Each compiled under 6.6.1:
      • A wrong annotated result type: Promise<{ no_such_column: Date }[]> for select('films', db.all, { columns: ['id', 'title'] as const }).run(q), or db.SQLFragment<{ no_such_column: Date }[]> for the query. Write the type of those columns, db.JSONOnlyColsForTable<'films', ['id', 'title']>[], or leave it to inference.
      • The rows assigned to an unrelated type: const n: number[] = rows. Map them to it: rows.map(r => r.id).
      • A wrong column type: const film: { id: string } = … where id is a number. Write the column's type, s.films.JSONSelectable['id'].
      • The whole row where only some columns come back: const rows: s.films.JSONSelectable[] = …. Write Pick<s.films.JSONSelectable, 'id' | 'title'>[], or leave columns out.
      • A union with an as const tuple, columns: cond ? ['id'] as const : cols: 6.6.1 typed the whole row; the rows are now either row, so a column that may be missing cannot be read. Check for it first ('title' in row).
      • A union of two queries, one with an as const tuple and one without, cond ? select('films', db.all, { columns }) : select('films', db.all): 6.6.1's never[] dropped out of the union, leaving the whole row. The union now keeps the narrower row, as it does when columns is a mutable array, so a column only one returns cannot be read (TS2339). The same holds for an array of both, a function that returns either, a ternary as a lateral key, and insert() or upsert() with and without returning. Give both the same columns, or read the row as a Partial<s.films.JSONSelectable>, where that column may be undefined.
      • A type computed from the row, type Film = Awaited<ReturnType<typeof cards.run>>[number] for const cards = select('films', db.all, { columns }): it was never, which took any column. Film['release_year'] (TS2339), 'release_year' as a keyof Film (TS2322, or TS2345 as an argument) and Film extends { release_year: number }, now false, name a column the query does not return: name it on the table's type, s.films.JSONSelectable['release_year'], keyof s.films.JSONSelectable, or add it to columns. {} as a Record<keyof Film, …> now lacks those keys (TS2739): write Partial<Record<keyof Film, …>>.
      • A hand-written copy of 6.6.1's inner conditional (C extends ColumnForTable<T>[] ? JSONOnlyColsForTable<T, C> : never), assigned to or from a function that forwards columns or returning, such as a .d.ts compiled against 6.6.1, which inlines it. Write db.ReturningTypeForTable<T, C, E> or db.FullSelectReturnTypeForTable<…>, or compile the .d.ts again.
    • Unchanged: a mutable array, no columns, the aggregates (number), and every type with strictNullChecks off, where the result is the whole row whatever columns.
  • The docs site passes Lighthouse's accessibility audit (100 on / and /select, from 94). Code comments reach WCAG AA's 4.5:1 on every code background, light and dark: --c is #626e79 in light (from 3.32:1) and #768794 in dark (from 4.13:1). The cast panel's column labels are paragraphs, not <h5>, and the home's four pillars <h2>, not <h3>, so no heading skips a level; both look as before.
  • Interpolating null (sql`${null}`, in an array, as a condition's operand such as not(null), as the where of a shortcut) throws a plain Error that says what to give instead: "Interpolating null has no meaning in SQL: for a NULL value, interpolate db.param(null); to filter on NULL, use { column: db.conditions.isNull }; to match every row, give a shortcut db.all as its filter." Zapatos 6.6.1 threw a TypeError ("Cannot convert undefined or null to object"). In a nested query, parent() with no column and no current column (sql`SELECT ${parent()}` as a lateral subquery; vals([parent()]) outside a filter, since it is the parent column, above) throws before writing the parent table: "db.parent() with no column has no meaning here: name the column, db.parent('id'), or give it as the value of a Whereable or vals({…}) key, whose column it then takes." In a sql template, Zapatos 6.6.1 wrote "t"., then threw "Alien object while interpolating SQL: undefined". Both still throw while compiling, so .run() rejects before any query. Nothing that compiled before this fix changes, in text or values: null as a value ({ col: null }, vals(), eq(null), isIn([null])) and param(null) are still a NULL parameter (R58).
  • The docs site's error lines, the red block under an example that fails to compile, reach WCAG AA's 4.5:1, so /conditions and /first-query score 100 for accessibility in Lighthouse, from 96. A new token, --error, colours their text and border, on a 12.5% tint of itself: #b22e2e in light (from Twoslash's #d45656, 3.1:1) and #d96d6d in dark (from 4.01:1).

6.6.2 12 November 2026 ​

Changed ​

  • Package renamed to pgcast, published on npm as @pgcast/pgcast (npm refuses the unscoped name, too close to recast); install it as zapatos@npm:@pgcast/pgcast. The CLI is available as both pgcast and zapatos. Module names (zapatos/db, zapatos/schema, zapatos/custom) are unchanged.
  • Toolchain: TypeScript 5.9, ESLint 9 (native flat config), typescript-eslint 8, @stylistic/eslint-plugin. The toolchain change alone leaves the build output (dist/, db.mjs, generate.mjs) byte-identical to 6.6.1; the fixes below change dist/.

Removed ​

  • The noisy postinstall message.
  • The postversion script that pushed and published to npm automatically.

Added ​

  • prepack builds before any npm pack / npm publish.
  • Vitest harness (test, test:watch, test:types) and a typecheck script.
  • AGENTS.md (+ CLAUDE.md symlink) and design decision notes under docs/. The roadmap lives in GitHub issues: one epic per chapter, one sub-issue per step, closed by its pull request.
  • CI matrix: Node 20, 22 and latest LTS; draft PRs run no job.
  • Agent workflow: .agents/skills/, .claude/agents/ (builder, branch-reviewer, chores), .claude/hooks/guard.mjs, npm run check:agents, docs/LEARNINGS.md.
  • Step workflow, drawn in docs/WORKFLOW.md: an issue template with Files and Risk, a plan comment before code, Proofs in each PR, review comments in Conventional Comments, a merge gate by risk (Ship / Show / Ask) and squash merges. npm run check:agents reports the size of each skill and agent.
  • Integration test harness: reference fixture test/fixtures/schema.sql (Postgres 14+), a template database built once per run, a fresh database per test file and a ROLLBACK per test (useTestDatabase(), test/helpers/db.ts). Needs TEST_DATABASE_URL (the environment, else src/.env); tests only create and drop pgcast_test_* databases.
  • CI matrix: Postgres 14, 16 and 18 service containers × Node 20, 22 and latest LTS.
  • Codegen characterization (test/integration/codegen.test.ts): the output for the fixture is frozen per configuration in test/__snapshots__/codegen/; the main configuration's files, as generate() writes them, are committed in test/generated/ and compiled by npm run typecheck against the declarations of src/db.
  • Injection characterization: the compiled text of every entry point of core.ts and shortcuts.ts that writes a caller's string into SQL, under hostile inputs (test/unit/injection.test.ts), and what those inputs achieve against Postgres, with and without parameters (test/integration/injection.test.ts). Nothing is fixed yet.
  • Runtime characterization: the text + values of each core.ts branch, shortcut and condition, frozen by inline snapshots (test/unit/), then run against the fixture (test/integration/), surprises included (// characterization: comments); transaction() commit, rollback, pass-through, retries and listener messages. The harness exposes the file's pool for code that commits (useTestDatabase().pool).
  • Type tests (test/types/, npm run test:types): the public types, positive and negative, on the fixture's generated schema and the emitted declarations of src/db, as a consumer compiles them; test:types emits those declarations first. Every codegen variant but edge is compiled with its custom types, and orphan snapshot files are reported.
  • Type-checking cost: npm run bench:types compiles bench/, a few dozen typed queries on the fixture's generated schema (nested lateral, columns, extras, writes with returning, conditions, sql<…>), and fails when Instantiations or Types, net of TypeScript's libs and @types, grow by more than 20 % on bench/baseline.json (measured on the 6.6.1 code); also fails when they drop by more than 5 % or when the TypeScript version differs, so that the baseline follows (-- --update). Runs in CI as its own job, without Postgres.

Security ​

  • lock.for and lock.wait of select(), the options of truncate() and the isolation level of transaction() are checked at run time, as order.direction already was.
    • A value the type refuses now throws before any query, instead of being written raw into the SQL (where lock.for could run a second statement).
    • The SQL for valid values is unchanged, as it is for an undefined or null that gets past the type without strictNullChecks (lock.wait, truncate() options).
  • The aggregate of select(), its fifth argument, must now be a function name as Postgres lexes it.
    • Plain, quoted or U& quoted, in up to three dotted parts (sum, my$agg, my_schema."My agg", my_db.my_schema.my_agg), with or without spaces around it and its dots.
    • Anything else, U&"…" UESCAPE '…' included, throws before any query, instead of being written raw into the SQL (where it could run a second statement).
    • The SQL of count(), sum(), avg(), min(), max() and of a valid name is unchanged, byte for byte, as it is for null; outside SelectResultMode.Numeric the argument is still ignored.
  • The type of param(x, cast) can no longer close its quotes.
    • A type that is well-formed once wrapped in " (non-empty quoted identifiers joined by ., with or without whitespace around it, such as pg_catalog"."int4) is written as is, as before: CAST($1 AS "pg_catalog"."int4").
    • Any other is quoted as one identifier, with any " doubled (as quote_ident does): param(1, 'int4") || (SELECT secret FROM t) || ("int4') now compiles to CAST($1 AS "int4"") || (SELECT secret FROM t) || (""int4"), a type that does not exist (42704), instead of a cast that reads another expression.
    • A type without " compiles as before.
    • The only casts that ran under 6.6.1 and no longer do have something other than whitespace between their quoted parts: an expression, which is the hole this closes, or a comment (pg_catalog"/**/."int4), now a type that does not exist (42704).
  • A table or column name given as a string (Table, Column, a Whereable key, cols, parent(), columns, order.by, …) can no longer close its quotes: any " in it is doubled, as quote_ident does, and it is still split on ..
    • A Whereable key a" = "a" OR TRUE OR "a now compiles to WHERE ("a"" = ""a"" OR TRUE OR ""a" = $1), a column that does not exist (42703), instead of WHERE ("a" = "a" OR TRUE OR "a" = $1), which returned every row; without parameters, such a name could run a second statement.
    • A name without " compiles as before, as does one that 6.6.1 already compiled to quoted names only (a hand-escaped a""b, for the column a"b) and one that is already a well-formed quoted name (below).
    • The cost: a real column named a""b, which the generated Column type spells 'a""b', still compiles to "a""b", the column a"b, as under 6.6.1.
    • The only names that ran under 6.6.1 and no longer do close their quotes to splice SQL around them: an alias (id" AS "x), a table alias (users" "u), a cast (id"::"text), a sort or a second item (id" DESC, "name), a collation (display_name" COLLATE "C), an expression (display_name" || "email) or a comment (users"/**/."id), each now a column or table that does not exist (42703, 42P01).
    • The generated Table and Column types, unions of literal names, already refused all of them; write those in a sql template, with only the name interpolated.
  • The lateral keys of select() (and of selectOne(), count(), …), its alias and its table name can no longer close their quotes where they become the aliases "lateral_…" and "sq_…".
    • Each is one identifier there, any " doubled as quote_ident does, not split on ..
    • An alias u"; DROP TABLE users; -- now ends AS "sq_u""; DROP TABLE users; --", one alias, as it is everywhere else in the query; with the fix above alone, it ended AS "sq_u"; DROP TABLE users; --" and, without parameters, ran a second statement.
    • A name without " compiles as before, as does one that 6.6.1 already wrote as one quoted name (a""b).
    • Replayed in their 6.6.1 form, no key, alias or table name that returned rows under 6.6.1 stops, but one that spliced SQL around "lateral_…" or "sq_…" and still ran, with parameters too, now runs as one name, with a different result:
      • Before this fix, a lateral key could read other tables: selectOne('users', { id: 1 }, { columns: ['id'], lateral: { [key]: count(…) } }), with the key x" || ') AS result FROM "users" LEFT JOIN LATERAL (SELECT (SELECT string_agg(email::text, chr(44)) FROM users) AS result) AS "lateral_x, opened a string literal in one "lateral_…" and closed it in the other, and returned every user's email under the key; it now returns the count.
      • The alias x" ("result") GROUP BY "result, with columns: ['email'], returned one user, and now returns them all.
    • A key or alias with a lone " (a"b) was a syntax error under 6.6.1 (42601), and now runs; select() also reaches a table whose name has a " ('my"table').
    • The cost, as for a string identifier: a""b reads as a hand-escaped a"b, so the lateral keys a"b and a""b given together get the same alias, which Postgres refuses (42712).
  • A string that starts and ends with " is written as is only if it is already a well-formed name as Postgres lexes it; any other is quoted like any other name (above).
    • Well-formed: a quoted identifier, then any quoted, U& quoted or unquoted ones, each after a ., with or without whitespace around it ("a", "a"."b", "a""b", "a.b", "public".users."display_name", "users".U&"display_name"), the parts the aggregate of select() accepts.
    • 6.6.1 wrote as is anything that started and ended with ", even a lone ": without parameters, the column "display_name" FROM users; DROP TABLE users; SELECT "display_name" ran a second statement, and now compiles to """display_name"" FROM users; DROP TABLE users; SELECT ""display_name""", a column that does not exist (42703).
    • "; DROP TABLE t; --" is one quoted name, the column ; DROP TABLE t; --, and is still written as is.
    • Replayed against Postgres, the only such strings that ran under 6.6.1 and no longer do close their quotes to splice SQL around them: a statement (above) or a clause ("display_name" FROM "users" UNION SELECT "display_name"), an alias ("display_name" AS "x"), a table alias ("users" "u"), a cast ("display_name"::"text"), a sort or a second item ("display_name" DESC, "email", "display_name", "email", "users", "accounts"), a collation ("display_name" COLLATE "C"), an expression ("display_name" || "email", UESCAPE clauses included), a join ("users" NATURAL JOIN "accounts") or a comment between the parts ("users"/**/."display_name"), each now a column or table that does not exist (42703, 42P01).
    • Strings that were a syntax error under 6.6.1 now name something that does not exist: " and "" the column ", "a"b" the column "a"b"; as the alias of select(), they now run as one alias, as does "u" AS "v".
  • A plain value in the updateValues of upsert() is now sent as a parameter, as its type and update() already said.
    • { mood: 'happy' } compiles to DO UPDATE SET ("mood") = ROW($n), instead of ROW("happy"), an identifier.
    • Under 6.6.1, such a string could close its quotes: xmin" || (SELECT string_agg(email::text, ',') FROM users) || "xmin wrote every user's email into the column.
    • Under 6.6.1, a number, a bigint or a boolean threw "Alien object", null a TypeError, an object or a class instance (a Date, a Buffer, a custom type with toPostgres()) became a WHERE-style condition of its keys (TRUE without keys), and an array its items one after the other (['a', 'b'] gave the identifier "a""b", [1, 2] threw, [] gave ROW(), a syntax error): each is now a parameter, with the same JSON casts as in update().
    • A value that holds a sql, param(), raw(), cols(), vals() or parent() object, Default or self, at any depth of its arrays and plain objects, compiles as before, byte for byte: [db.sql`upper(EXCLUDED.name)`], { 'users.name': db.conditions.like('B%') }, or what mapWithSeparator() returns when it holds a fragment (a sql separator, say) or a param(); so a string beside a fragment in an array is still an identifier.
    • What mapWithSeparator() returns for one string, or for none, is a plain array, now a parameter.
    • A function or the global object, alone or in an array, still throws.
    • Replayed against Postgres on a text column, a bare column name never ran (ROW("display_name") is ambiguous between the existing row and EXCLUDED, 42702), but these did, and are now stored as data:
      • a qualified column ('users.display_name', 'public.users.display_name', 'excluded.display_name'), a whole row ('users', 'excluded') or a system column ('xmin', 'ctid'), which read that column as text;
      • an object with no own enumerable keys ({}, a Date, an empty Buffer, a Map, a Set), stored as true;
      • an object or a class instance whose keys all name such a column ({ 'users.display_name': 'Bob' }, { xmin: '1' }), stored as true or false;
      • a class instance that holds a fragment (new (class { x = db.sql`'Bobby'` })()), which stored the fragment's result;
      • or an array of one of them, what mapWithSeparator() returns for one string included.
    • To keep the 6.6.1 result, put the value in a fragment: any value other than undefined (which still means the key is absent) compiles inside db.sql`${value}` byte for byte as it did in updateValues under 6.6.1, the name fixes above aside. A qualified column goes in as its parts: { tagline: db.sql`${'films'}.${'title'}` } for { tagline: 'films.title' }, as the SQL type of sql refuses db.sql`${'films.title'}` (TS2345), in Zapatos 6.6.1 too.