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 ofllms-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
/playgroundloads the editor, about 2.7 MB gzipped. The top bar's Changelog becomes Playground; the changelog is linked from the footer.monaco-editor0.57.0 is a dev dependency ofwebsite/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.Poolof one connection withpg's parsers (int8a string,byteaaBuffer,datelocal,point{ x, y }), and sends values aspgsends them (itsprepareValue, ported: an array as an array literal, aDatein local time, an object as JSON); each query is prepared, so a text of several statements rejects with 42601.npm run docs:exampleschecks it parses each table of the docs schema and takes a list of values aspgdoes, and runs every example on it too.@electric-sql/pgliteis a dev dependency ofwebsite/alone. - The playground's Share button, beside Run: it puts the code in the page's URL,
#code/and the code deflated (the browser'sCompressionStream) 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
upsertRequiresSameKeysconfig option (db.setConfig({ upsertRequiresSameKeys: true }), off by default, optional inConfig): a multi-rowupsert()withoutupdateColumns, where a key missing from some row is still set fromEXCLUDED, throws before any query ("rows have different keys: pass updateColumns, or give every row the same keys"), instead of overwriting the existing value with itsDEFAULT. A key ofupdateValuesor a column of an array or stringconflictTargetdoes not count (new rows without their serialid, upserted on'id', pass); withconstraint(), every key counts. Rows with the same keys, one row, and anyupdateColumnscompile as before. - The
undefinedMeansMissingconfig option (db.setConfig({ undefinedMeansMissing: true }), off by default, optional inConfig): anundefinedvalue acts as a missing key.insert()sendsDEFAULTfor 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 unlessupdateColumnsnames it (then it is still set fromEXCLUDEDand an existing row takes the columnDEFAULT, as for a missing key), and in several rows its cell takesDEFAULTand counts as missing forupsertRequiresSameKeys. Anundefinedin aWhereableor inupdateValuesis unchanged. - The
transactionRequiresCommitconfig option (db.setConfig({ transactionRequiresCommit: true }), off by default, optional inConfig): when the callback oftransaction()catches a query's error and returns, Postgres answers theCOMMITwithROLLBACKand saves nothing;transaction()then rejects ("the transaction was rolled back, not committed: …"), without a retry nor aROLLBACKof its own, and releases its client as before. The SQL and what the listeners get are unchanged; aROLLBACK TO SAVEPOINTafter the error lets the transaction commit. - The
selectExactlyOneChecksForSecondRowconfig option (db.setConfig({ selectExactlyOneChecksForSecondRow: true }), off by default, optional inConfig): aselectExactlyOne()run on its own asks forLIMIT 2(… WHERE ("id" = $1) LIMIT $2,$2being2) and rejects with aNotExactlyOneErroron a second row ("One result expected but more returned …"). Nested in another query, in alateralor asqltemplate, it keepsLIMIT 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
typeEnumsAcrossSchemasoption ("typeEnumsAcrossSchemas": trueinzapatosconfig.json, off by default, optional inConfig): 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 thanany. 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
enumOrderoption ("enumOrder": "declared"inzapatosconfig.json, default"alphabetical", optional inConfig): an enum's labels keep their declared order, by which Postgres sorts and compares its values (enumsortorder, a label added byALTER TYPE … ADD VALUE … BEFOREincluded), rather than the alphabetical one: in its type, in itsevery.<name>tuple (['sad', 'ok', 'happy']), and, withtypeEnumsAcrossSchemas, 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 ofdocs/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 towebsite/orsrc/, 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. Arobots.txtat its root lets every crawler in, assistants' included, and names thesitemap.xmlthat the build now writes.llms.txtandllms-full.txtgive the new URLs, and the link check readshttps://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 setsdescription), a canonical URL, Open Graph and a Twitter card with one social image, and on the home a JSON-LDSoftwareSourceCode; the site has a favicon (::cut out of a teal tile, light and dark), an Apple touch icon and atheme-color. Each page shows its "Last updated" date andsitemap.xmlitslastmod, from git, so CI builds the docs from the whole history.npm run docs:images, inwebsite/, renders the PNG and ICO from their SVG. The package'shomepageis 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/schemathat 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 (tscruns 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.txtandllms-full.txtto 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
docsjob, now with a Postgres service, regenerates the docs schema's types (website/zapatos/) fromwebsite/schema.sqland fails if they differ from the committed files. - The docs build fails when an export of
zapatos/db, or an option of itsConfigor of the generator's, has no entry in a Reference page (a heading anchored{#name}) and is not inwebsite/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 fromCONTRIBUTING.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 feedsllms.txt, screenshots for a PR, and a checklist. One template per group inwebsite/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 firstselectOne(),select()withcolumnsandinsert()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.txtnow 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()andtruncate()(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-insupsertRequiresSameKeysandundefinedMeansMissing; 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 inwebsite/reference-pending.jsoncounts 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
docsjob 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:schemaanddocs:examplesdrop 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
sqlblocks, 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 builtzapatos/dbmust 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()andselectExactlyOne()send theirLIMITas a parameter, its value1, notLIMIT 1. The Config page'sisIn()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 ofenableCustomJSONParsingForLargeNumbers(), 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-7job 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 ofsrc/dbthat the pinned TypeScript 5.9.3 emits; no Postgres. The build and the other checks stay on 5.9.3.tsconfig.jsondropsbaseUrlandtest/generated/tsconfig.jsonmakes itspathsrelative, which TypeScript 7 requires (TS5102, TS5090);dist/and the declarations are unchanged. ReturningTypeForTableandUpsertReturnableForTableexported fromzapatos/db, with their Zapatos 6.6.1 names and parameters: one row of whatinsert(),update()anddeletes()return, and of whatupsert()returns, with$actionunlessreportAction: 'suppress'. A test can type a fake result from the schema. Type tests pin them,FullSelectReturnTypeForTableandRunResultForSQLFragmentto 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 changesdependenciesorpeerDependencies.
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 andllms.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.txtand 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 ofpackage.json. - The JSDoc of
constraint()andupsert(), and the Reference entry ofconstraint(), say that the type does not check that a constraint belongs to the upsert's table, which Postgres refuses (42704): adb.constraint('…')held in a variable takes any table's name, anddb.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
constwith 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 thefilms.Insertableit 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.mdrewritten 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
authorsandbooks(decision 004, amended): the docs schema (website/schema.sql) holdsstudios,films,actors,roles,crew,cinemas,screenings,seat_bookings,ratingsandtags, 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: theauthoris the maintainer, Alexandre Demartin; George MacKerron, the original author of Zapatos, is listed undercontributors..github/FUNDING.ymllists both. The published files are unchanged.- The docs of
strict()andtoBuffer(), in their JSDoc and Reference entries, say that onlynullpasses through, as onlyNULLdoes for aSTRICTfunction in Postgres:undefinedreaches the wrapped function, which handles it if it takes it and may throw otherwise, and the type refuses it unless the function takes it, withstrictNullCheckson. The behaviour is Zapatos 6.6.1's, unchanged. - The Conditions page and the JSDoc of
add()andsubtract()show date arithmetic as asqlfragment value, as their type takes a number or aDate, not aParameter(TS2345):db.sql`${db.self} + ${db.param(3, 'int4')}`adds three days to adate, anintervalparameter a day to atimestamptz. On adatecolumn, the type refuses a number (TS2322), which Postgres finds ambiguous (42725); aDatetype-checks ondate,timestampandtimestamptzcolumns, and Postgres refuses it. The behaviour is Zapatos 6.6.1's, unchanged (R33). - The Conditions page and the JSDoc of
arrayContains(),arrayContainedIn()andarrayOverlaps()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 asT, its value an array of arrays (arrayOverlaps<string[]>([['romance', 'thriller']])), or asqlfragment value with a cast parameter,db.sql`${db.self} @> ${db.param(['drama'], 'text[]')}`, which also runs withcastArrayParamsToJsonon, 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 intozapatos/schema.d.ts. The reference pages of thesqltemplate,select(), the writes anddb.conditionsfollow, 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()withoutupdateColumns, where a key missing from some row is still set fromEXCLUDED: it is inserted asDEFAULT, then overwrites the existing value on conflict. It still runs, with the same SQL, and emits aDeprecationWarning(PGCAST_DEP001) once per process.upsertRequiresSameKeyswill be the default in 7.0. - An
undefinedvalue ininsert(),update()orupsert()values, sent asNULL: aNOT NULL DEFAULTcolumn fails, andupdate()erases the column instead of leaving it. It still runs, with the same SQL, and emits aDeprecationWarning(PGCAST_DEP002) once per process.undefinedMeansMissingwill be the default in 7.0. - A
transaction()whoseCOMMITPostgres answers withROLLBACK, 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 aDeprecationWarning(PGCAST_DEP003) once per process, only when that happens.transactionRequiresCommitwill be the default in 7.0. - A column whose type is an enum of another schema, typed as an
anycustom 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 throughwarningListeneronce per run, naming each such column and its enum (public.products.status (extra.status)).typeEnumsAcrossSchemaswill 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']forENUM ('sad', 'ok', 'happy')), not in the order Postgres sorts and compares them by. The generator still writes them so, and notes it throughwarningListeneronce 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 anincludelist 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 whoseexcludeis"*"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 domainnotesin two schemas, or two names thatcustomTypesTransformmakes one (seat-labelandseat_labelunderPgMy_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, givecustomTypesTransforma function. Nothing generated changes: the placeholders keep their names, which edited files depend on (R42). - A
Parameteras aWhereablevalue, or invals([…])(soisInandisNotIn), 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"}').Defaultas aWhereablevalue (or inisIn) throws: "db.Defaulthas no meaning in a Whereable (a filter): leave the key out, or compare with a value"; invals([…])elsewhere it givesDEFAULT, as invals({…}), except inside a fragment value ofvals({…})(below). Results that change:isNotIn([param(x)])andnot({ col: param(x) })matched every row; they now leave outx. Sodeletes(table, { col: isNotIn([param(x)]) })deleted every row, and now keeps those equal tox.deletes(),update()andselect()/count()with aParametervalue on a column whose type accepts the JSON text as input (text, varchar, char, name, bytea) orjsonbfound nothing; they now reach their rows, and write.isIn([param(x), y])found onlyy.- In
upsert()updateValues, a plain object is aWhereableof 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 onlyahas the roleadmin, storedfalseon both rows; it now storestruefora. WithisNotIn([param('guest')]), where onlybhas the roleguest, it storedtrueon both, and now onaonly. WithDefault, it now throws. - A hand-written
sql`INSERT … VALUES (${vals([…, param(x)])})`stored'{"value":…}', andvals([…, Default])stored'Symbol(DEFAULT)'in a column whose type accepts it as input (text, varchar, name, citext, bytea, tsvector, xml); they now storexand the column default. - A
Parameteron anint, enum, array or other column failed (22P02; 22007 on date and time types; 42601 ontsvectorandtsquery), andDefaultinvals([…])outside a filter failed on a column that refuses'Symbol(DEFAULT)'as input; they now match, or store. vals([Default])inside a fragment value ofvals({…})(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 aWhereablevalue is.{ col: param(x, true) }on ajsonbcolumn found nothing; it now fails asjsonb = json(42883), assql`${self} = ${param(x, true)}`does.Defaultin a filter found nothing (text) or failed with 22P02; it now throws while compiling, so.run()rejects with a plainError, not aDatabaseError.- Unchanged: a
raw()value (#101; refused since #108), an array that holds aParameter({ tags: [param(x)] });param(param(x))stays one parameter, its value the innerParameteras 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 bareANDorORat its top level now matches other rows: asqlfragment such assql`${self} = 1 OR ${self} = 2`(or an array of fragments), alone, insideand()/or()or under anothernot(). Any other condition (eq,isNull,isIn,between,like,and(),or(), aParameter, aWhereable) gains one pair of parentheses in its text and matches the same rows.update()with anUpdatablethat 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 toSET () = ROW(), which Postgres refused (42601), so no call that ran stops; the error now comes fromupdate()itself, not from.run(). A key whose value isundefinedstill counts, and sets the column toNULLas before.Defaultandselfgiven where they have no meaning throw while compiling, so.run()rejects with a plainError, not aDatabaseError, 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 withne()orisNotIn()(sodeletes()deleted them all), 22P02 on the others. Each form that now throws:Defaultorselfas the value ofeq(),ne(),gt(),gte(),lt(),lte(),after(),before(),isDistinctFrom(),isNotDistinctFrom(), either bound of the fourbetween(), the tenlike()/similarTo()/reMatch()conditions,arrayContains(),arrayContainedIn(),arrayOverlaps(),add()andsubtract(): "db.Defaulthas no meaning as the value ofeq(),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. Soupdate(table, { n: add(Default) }, …)throws instead of failing with 22P02, and its message gives the fix:{ n: db.Default }.selfas aWhereablevalue ({ name: self }), inisIn()/isNotIn(), or invals([…])within a filter or a fragment value ofvals({…})(insert('users', { …, tags: sql`ARRAY[${vals([self])}]::text[]` })stored{Symbol(self)}; it now throws, inupdate()too): "db.selfhas no meaning as a value in a Whereable (a filter), nor inisIn(): compare with a value, or use it in asqlfragment value…".- The same values wherever a
Whereableis compiled: thewhereofselect(),count(),update(),deletes(), alateralsubquery (lateral: { n: count('books', { authorId: self }) }failed with 22P02), and a plain object inupsert()updateValues({ settings: { theme: self } }failed with 42703). - Unchanged:
selfin a fragment ({ n: sql`${self} > 0` }),isIn([Default])(it threw already), and outside a filterparam(Default),param(self),vals([self])andvals({ a: self }), still sent as strings. A condition directly inupsert()updateValues({ n: add(Default) }) throws "The 'self' column alias has no meaning here", as before.
parent()invals([…]), so inisIn()andisNotIn(), compiles to the parent column, as aWhereablevalue,eq()and asqltemplate already do: in alateralsubquery ofauthors,{ authorId: isIn([parent('id')]) }gives("authorId" IN ("authors"."id")); without a column,parent()takes the column of the filter; with no filter, it throws, assql`${parent()}`does (R58). Outside a nested query it throws "The 'parent' table alias has no meaning here", so.run()rejects with a plainErrorbefore any query. Zapatos 6.6.1 sent it as one parameter, as JSON ('{"value":"id"}'). Results that change:- In a
lateralsubquery,isIn([parent(c)]),isIn([parent(c), x]),isIn([parent()])andisNotIn([parent(c)])failed with 22P02 on an int column; on a column whose type accepts it as input (text, jsonb…),isInfound nothing andisNotInmatched every row ({ settings: isIn([parent('settings')]) }onjsonb). They now compare with the parent column, in nested laterals (the nearest parent), with analiasand in a passthroughlateral. The same goes forvals([parent(c)])written by hand in any fragment of the subquery: a fragment value ({ authorId: sql`${self} IN (${vals([parent('id')])})` }), anextrasfragment (extras: { a: sql`ARRAY[${vals([parent('display_name')])}]` }returned['{"value":"display_name"}'], and now the parent's value) or asqllateral subquery. - Outside a nested query, they now throw where
select()andcount()found nothing on a column whose type accepts it as input (text, jsonb…), or every row withisNotIn, wheredeletes(table, { title: isNotIn([parent('title')]) })deleted every row andupdate()withisNotInupdated 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-writtensql`INSERT … VALUES (${vals([…, parent(c)])})`orARRAY[${vals([parent('t')])}]({"{\"value\":\"t\"}"}in atext[]), a fragment value ofinsert(), ofupdate()or in the inserted values ofupsert()({ title: sql`${vals([parent('name')])}` };{"value": "x"}in ajsonbcolumn), andvals([parent(c)])inupsert()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 aninsert()value ({ title: parent('name') }, sent as JSON, #112, until it throws, below), a fragment inisIn(isIn([sql`${parent('id')}`]), already the column),eq(parent('id'))and aparent()value.
- In a
raw(),cols(x)andvals(x)given as a filter value throw while compiling, so.run()rejects with a plainError, not aDatabaseError, before any query; the type refuses them wherever aParameter<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 andjsonb, every row withne()orisNotIn()(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: asqlfragment value,{ name: sql`${self} = ${raw("'Ada'")}` }; or the value itself, orparam(x).- They throw as a
Whereablevalue ({ name: cols('Ada') },{ id: vals(1) }), inisIn()/isNotIn(), and invals([…])within a filter or a fragment value ofvals({…})(insert('users', { …, name: sql`${vals([raw("'Cy'")])}` })stored{"value":"'Cy'"}): "db.raw(),db.cols()anddb.vals()have no meaning as a value in a Whereable (a filter), nor inisIn(): to compare with SQL, use asqlfragment value, wheredb.selfstands for the column; to compare with a value, give the value itself, ordb.param()." So wherever aWhereableis compiled: thewhereofselect(),count(),update(),deletes(),and(),or(),not(), alateralsubquery, and a plain object inupsert()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 fourbetween(), the tenlike()/similarTo()/reMatch()conditions,arrayContains(),arrayContainedIn(),arrayOverlaps(),add()andsubtract()(update('accounts', { n: add(raw('1')) }, …)failed with 22P02): "db.raw(),db.cols()anddb.vals()have no meaning as the value ofeq(),add()and the other conditions: for SQL, write asqlfragment withdb.selfin place of the condition; for a value, give the value itself, ordb.param()." - They no longer type-check where the type took them as a
Parameter<T>, which is structural ({ value: T }):raw()on a text orjsonbcolumn,cols(x)andvals(x)on any column withxof its type, as aWhereablevalue, inisIn(),isNotIn(),eq()and the other conditions; and as aninsert(),update()orupsert()value, where they are still sent as JSON at run time ({"value":"'Cy'"}stored in a text column, 22P02 ontext[]; #132).Parameter,DangerousRawString,ColumnNamesandColumnValueseach get a type-only member for this; no JS changes. SoDangerousRawString,ColumnNamesandColumnValuesno longer match a plain{ value }nor each other;Parameterstill does:const r: db.DangerousRawString = { value: 'x' }(TS2741) andconst n: db.ColumnNames<string[]> = db.vals(['a'])(TS2322) type-checked, and now fail. - Nor where it took
cols(x)andvals(x)as aParentColumn, which is all-optional ({ value?: Column }): withxa column name orundefined, as aWhereablevalue ({ name: vals(undefined) }), ineq()({ id: eq(cols('id')) }),isIn()/isNotIn()(isIn([vals('email')])) and the other conditions, alateralsubquery, and as the argument ofarrayContains(),arrayContainedIn()andarrayOverlaps(); each threw at run time.parent()andparam(x)still fit; no emitted JS changes. - In
upsert()updateValues, compiled as in asqltemplate, they still type-check where Zapatos 6.6.1 took them as the column'sParameter<V>:raw()whereVtakes a string (text,jsonb),cols(x)andvals(x)withxof the column's value typeV({ manager_id: vals('nonsense') }is refused, as before). A plainsql`…`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 withnulland refused it.cols(x)andvals(x)of any value still type-check in asqltemplate. - 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.
- They throw as a
parent(),raw(),cols(x)andvals(x)as aninsert(),upsert()orupdate()value throw while compiling, so.run()rejects with a plainErrorbefore 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"}forparent('id')and{}forparent(), ajsonbcolumn{"value": …}, atext[]column[]forparent(), andupsert()set it on the existing row fromEXCLUDED; 22P02 on int, an enum or a composite.- The check sits in
vals({…}), which compiles the writes' values, so asqltemplate of your own throws too:sql`INSERT … VALUES (${vals({ name: raw('now()') })})`.raw(),cols()andvals()throw "db.raw(),db.cols()anddb.vals()have no meaning as a value invals({…})…", in a nested query or not;raw()is never compiled as SQL there, which would make araw(input)an injection. parent()invals({…})compiles as invals([…]): the parent column in a nested query, without a column the column of its key (sql`SELECT ${vals({ name: parent() })}`, in alateralofauthors, 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(azapatos/customplaceholder, a composite orxml), and invals({…})of asqltemplate, where 6.6.1 stored the JSON text; the type refuses them on every other column of anInsertableorUpdatable, as before. - Unchanged: a wrapper nested in an array or a
jsonbobject ({ tags: [raw(x)] }, still JSON, which the type refuses),upsert()updateValues(where they are SQL),selfinvals({…})(sent as a string) andvals([raw(x)])outside a filter (sent as JSON), and the SQL of every call that compiled.
- The check sits in
- 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
```tsline inside a```mdblock 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
Whereablethrows while compiling, so.run()rejects with a plainErrorbefore any query:sql`DELETE FROM ${'films'} WHERE ${new Date()}`compiled toDELETE FROM "films" WHERE TRUEand deleted every row (#170).- Refused:
Date,Map,Set,WeakMap,WeakSet,RegExp,Promise,Errorand its native subtypes,ArrayBuffer, a typed array,Buffer,DataView, and the boxesNumber,StringandBoolean, even with own keys added (Object.assign(new Date(0), { id: 1 }),("id" = $1)in Zapatos 6.6.1), wherever aWhereableis compiled: asqltemplate,and()/or()/not(), thewhereofselect(),count(),update(),deletes(), alateralsubquery. 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 aPromisefirst. - The type refused most of them (TS2345), not one that shares a member with a column:
deletes('actors', new Error('x'))type-checks whereactorshas anamecolumn, and now throws. - Unchanged: a plain object, an object with a
nullprototype, a class instance, even of a class that extends a built-in, a view included (class Frames extends Uint8Array {}:TRUEwhen it has no own enumerable key,("0" = $1 …)for its indexes), a built-in of another realm (aDatefromvm.runInNewContext():TRUE), a built-in as aWhereablevalue ({ released: new Date() }is$1), the wrappers, arrays, the global object's error.
- Refused:
setConfig()sets a key that has a default back to it when given asundefined(ornull, from JavaScript), sogetConfig()gives the default, as its typeConfigsays. Zapatos 6.6.1 kept theundefined: aftersetConfig({ transactionRetryDelay: undefined }), which compiles, everytransaction()on a pool rejected with "TypeError: Cannot destructure property 'minMs' …" and never released the client it had checked out, until the pool was exhausted; aftertransactionAttemptsMax: undefined, it gave up after one attempt.transaction()also releases its client now if reading the configuration throws. A listener has no default:undefinedstill removes it.getConfig()andsetConfig()return a copy of the configuration,transactionRetryDelayincluded: changing what they return no longer changes the configuration. Zapatos 6.6.1 sharedtransactionRetryDelayfromgetConfig()(getConfig().transactionRetryDelay.minMs = 1set it), andsetConfig()returned the configuration itself. The listeners are the functions you set, andsetConfig()still keeps thetransactionRetryDelayobject it is given.and()with no argument compiles toTRUE, andor()toFALSE: every row and no row, as aWhereable{}isTRUE. Zapatos 6.6.1 wrote(), which Postgres refuses (42601), alone, as aWhereablevalue, undernot()or inside anotherand()/or(). With one condition or more, only the parentheses of the next line change.- Every row:
update()anddeletes()givenand(...filters)ornot(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.
- Every row:
transaction()given apg.native.Client, with pg 7 and pg-native, runs on that client and leaves it connected, as for apg.Client. Zapatos 6.6.1 took it for a pool and called itsconnect(): the call rejected, with "Client has already been connected" for a connected client, or with aTypeErrorfor 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 bareORat its top level, asqlfragment such assql`${self} = 1 OR ${self} = 2`(from JavaScript, also araw()or an array of fragments, which the type refuses). Wherever theand()stands: first or last operand, aWhereablevalue, underor(),not()or anotherand(); thewhereofselect(),count(),update()anddeletes(), which now write or delete other rows; alateralbesideparent(), ahaving; and as a value, not a filter:extras,groupBy,distinct,order, aninsert(),update()orupsert()value (updateValuesincluded), asqltemplate. - 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 bareORat their top level: the conditions (eq(),isNull,isIn(),between(),like(),arrayContains()…),and(),or(),not(), aWhereable, a fragment withANDorNOTthere, or itsORin parentheses, aCASE, a function call or a subquery. In{ id: and(gt(1), lt(5)) }, theand()gives(("id" > $1) AND ("id" < $2)), where it was("id" > $1 AND "id" < $2). - Unchanged:
or().ANDbinds tighter thanOR, so no operand reaches across anOR.
- Other rows, or another value: an
- 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')givesCAST($1 AS "public"."mood")andparam(x, 'int4[]')givesCAST($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 typebin schemaa, and that type isparam(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 oftag, with no error when both exist, and that type isparam(x, '"tag[]"').'public"."tag[]'moves the same way. - Changed from pgcast 6.6.2 (R3):
param(x, '"q"')wasCAST($1 AS """q"""), the type"q"; it is nowCAST($1 AS "q"), the typeq. - 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 insql.
- Changed:
- A string cast of
param()now applies undercastArrayParamsToJsonandcastObjectParamsToJson, where Zapatos 6.6.1 replaced it withCAST($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[]')givingCAST($1 AS "int4"[])with[1, 2]. Any other array stays JSON text,'_int4'and a domain over an array included:param([1, 2], 'jsonb')givesCAST($1 AS "jsonb")with'[1,2]'. UndercastObjectParamsToJson, a plain object stays JSON text, whatever its cast. - Fixed:
=,@>and the writes on anint4[],text[]orjsonb[]column with a cast ending with[], and=and@>on ajsonbcolumn with'jsonb', now run (42883 or 42804 before). - Unchanged:
true,false, no cast, the array conditions given a plain array, an array undercastObjectParamsToJsonalone and a plain object undercastArrayParamsToJsonalone. - 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
jsonorjsonbcolumn, or a domain over one, any cast butjson,jsonbor such a domain is refused (42804):param(arr, 'int4[]'),param([1, 2], 'text'),param(obj, 'text'),param({ a: [1] }, 'jsonb[]'). Writeparam(x, 'jsonb'), or no cast. - Into a
textcolumn, 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 ajsoncolumn,param(x, 'jsonb')stores jsonb's spacing too. Writeparam(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 orparam({ a: [1] }, 'jsonb[]'), and no number forparam(arr, 'int4');param(['a'], 'int4[]')has an item that is noint4.'_int4'failed on 6.6.1 only into anint4[]column (42804, 42883), and ran intojson,jsonb,textand a SELECT: writeparam(arr, 'int4[]'). - A cast that names no type fails:
param(arr, '')andparam(arr, '[]')(42601), and'int4 []', the type"int4 "(42704).
- Into a
- Value sent: under
count(),sum(),avg(),min()andmax()throw while compiling on three options that Zapatos 6.6.1 compiled to SQL Postgres refuses, so.run()rejects with a plainError, not aDatabaseError, 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 alateralorextras, rejects too. The type is unchanged.sum(),avg(),min()ormax()withoutcolumns: 6.6.1 sentsum(*)(42883). The error is "sum() needs one column to aggregate: pass columns: ['column']".columnswith several columns, or none: 6.6.1 sentcount("id", "mood")(42883) orcount()(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 sentSELECT 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'] })sentmax("stars", "votes"); it now rejects.select('ratings', db.all, { columns: ['stars', 'votes'] }, SelectResultMode.Numeric, 'max')sends the same SQL. - Changed: a refused aggregate marked
noopresolved to itsnoopResult; it now rejects, asrun()compiles the query before it readsnoop. - Unchanged: every other call that ran, a
lateralmap included, andselect()inSelectResultMode.Numeric, which keeps several columns for an aggregate of several arguments (corr("rating", "minutes")).
- The generated
UniqueIndexof a table, soUniqueIndexForTable<T>and the namesconstraint()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 inON 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
ratingswith a plain indexfilm_stars_key, andarchive.ratingswhoseUNIQUEconstraint 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 namefilm_stars_keyunder 6.6.1, and now fails on it: that key in aRecord<s.UniqueIndexForTable<'ratings'>, string>(or overs.ratings.UniqueIndex), withsatisfiestoo (TS2353),case 'film_stars_key'in aswitchover one (TS2678),k === 'film_stars_key'(TS2367). Drop that key or case:ratings_pkeyis left.
- Changed: given
- A multi-line
COMMENT ON COLUMNputs each of its lines behind*in the generated JSDoc, whether it breaks them with\n,\r\nor\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 withschemaJSDoc: 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
readonlyarray ascolumnsorreturning,['id', 'title'] as constor areadonly ColumnForTable<T>[], types the result as a mutable array does: the rows of those columns, inselect(),selectOne(),selectExactlyOne(),insert(),upsert()(with$action),update(),deletes()and alateral. WithstrictNullChecks, Zapatos 6.6.1 typed these rows asnever(selectOne()asundefined), which any type accepted and whose properties could not be read.JSONOnlyColsForTabletakes a readonly tuple, where 6.6.1 refused it (TS2344);FullSelectReturnTypeForTable, which gaveneverfor one,ReturningTypeForTableandUpsertReturnableForTablegive 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 }[]>forselect('films', db.all, { columns: ['id', 'title'] as const }).run(q), ordb.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 } = …whereidis 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[] = …. WritePick<s.films.JSONSelectable, 'id' | 'title'>[], or leavecolumnsout. - A union with an
as consttuple,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 consttuple and one without,cond ? select('films', db.all, { columns }) : select('films', db.all): 6.6.1'snever[]dropped out of the union, leaving the whole row. The union now keeps the narrower row, as it does whencolumnsis 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 alateralkey, andinsert()orupsert()with and withoutreturning. Give both the samecolumns, or read the row as aPartial<s.films.JSONSelectable>, where that column may beundefined. - A type computed from the row,
type Film = Awaited<ReturnType<typeof cards.run>>[number]forconst cards = select('films', db.all, { columns }): it wasnever, which took any column.Film['release_year'](TS2339),'release_year'as akeyof Film(TS2322, or TS2345 as an argument) andFilm 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 tocolumns.{}as aRecord<keyof Film, …>now lacks those keys (TS2739): writePartial<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 forwardscolumnsorreturning, such as a.d.tscompiled against 6.6.1, which inlines it. Writedb.ReturningTypeForTable<T, C, E>ordb.FullSelectReturnTypeForTable<…>, or compile the.d.tsagain.
- A wrong annotated result type:
- Unchanged: a mutable array, no
columns, the aggregates (number), and every type withstrictNullChecksoff, where the result is the whole row whatevercolumns.
- Changed, with
- 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:--cis#626e79in light (from 3.32:1) and#768794in 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 asnot(null), as thewhereof a shortcut) throws a plainErrorthat says what to give instead: "Interpolatingnullhas no meaning in SQL: for aNULLvalue, interpolatedb.param(null); to filter onNULL, use{ column: db.conditions.isNull }; to match every row, give a shortcutdb.allas its filter." Zapatos 6.6.1 threw aTypeError("Cannot convert undefined or null to object"). In a nested query,parent()with no column and no current column (sql`SELECT ${parent()}`as alateralsubquery;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 orvals({…})key, whose column it then takes." In asqltemplate, 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:nullas a value ({ col: null },vals(),eq(null),isIn([null])) andparam(null)are still aNULLparameter (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
/conditionsand/first-queryscore 100 for accessibility in Lighthouse, from 96. A new token,--error, colours their text and border, on a 12.5% tint of itself:#b22e2ein light (from Twoslash's#d45656, 3.1:1) and#d96d6din 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 torecast); install it aszapatos@npm:@pgcast/pgcast. The CLI is available as bothpgcastandzapatos. 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 changedist/.
Removed
- The noisy
postinstallmessage. - The
postversionscript that pushed and published to npm automatically.
Added
prepackbuilds before anynpm pack/npm publish.- Vitest harness (
test,test:watch,test:types) and atypecheckscript. AGENTS.md(+CLAUDE.mdsymlink) and design decision notes underdocs/. 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:agentsreports 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 aROLLBACKper test (useTestDatabase(),test/helpers/db.ts). NeedsTEST_DATABASE_URL(the environment, elsesrc/.env); tests only create and droppgcast_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 intest/__snapshots__/codegen/; the main configuration's files, asgenerate()writes them, are committed intest/generated/and compiled bynpm run typecheckagainst the declarations ofsrc/db. - Injection characterization: the compiled text of every entry point of
core.tsandshortcuts.tsthat 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+valuesof eachcore.tsbranch, 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 ofsrc/db, as a consumer compiles them;test:typesemits those declarations first. Every codegen variant butedgeis compiled with its custom types, and orphan snapshot files are reported. - Type-checking cost:
npm run bench:typescompilesbench/, a few dozen typed queries on the fixture's generated schema (nestedlateral,columns,extras, writes withreturning, conditions,sql<…>), and fails whenInstantiationsorTypes, net of TypeScript's libs and@types, grow by more than 20 % onbench/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.forandlock.waitofselect(), the options oftruncate()and the isolation level oftransaction()are checked at run time, asorder.directionalready was.- A value the type refuses now throws before any query, instead of being written raw into the SQL (where
lock.forcould run a second statement). - The SQL for valid values is unchanged, as it is for an
undefinedornullthat gets past the type withoutstrictNullChecks(lock.wait,truncate()options).
- A value the type refuses now throws before any query, instead of being written raw into the SQL (where
- The
aggregateofselect(), its fifth argument, must now be a function name as Postgres lexes it.- Plain, quoted or
U"ed, 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 fornull; outsideSelectResultMode.Numericthe argument is still ignored.
- Plain, quoted or
- 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 aspg_catalog"."int4) is written as is, as before:CAST($1 AS "pg_catalog"."int4"). - Any other is quoted as one identifier, with any
"doubled (asquote_identdoes):param(1, 'int4") || (SELECT secret FROM t) || ("int4')now compiles toCAST($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 type that is well-formed once wrapped in
- A table or column name given as a string (
Table,Column, aWhereablekey,cols,parent(),columns,order.by, …) can no longer close its quotes: any"in it is doubled, asquote_identdoes, and it is still split on..- A
Whereablekeya" = "a" OR TRUE OR "anow compiles toWHERE ("a"" = ""a"" OR TRUE OR ""a" = $1), a column that does not exist (42703), instead ofWHERE ("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-escapeda""b, for the columna"b) and one that is already a well-formed quoted name (below). - The cost: a real column named
a""b, which the generatedColumntype spells'a""b', still compiles to"a""b", the columna"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
TableandColumntypes, unions of literal names, already refused all of them; write those in asqltemplate, with only the name interpolated.
- A
- The
lateralkeys ofselect()(and ofselectOne(),count(), …), itsaliasand its table name can no longer close their quotes where they become the aliases"lateral_…"and"sq_…".- Each is one identifier there, any
"doubled asquote_identdoes, not split on.. - An
aliasu"; DROP TABLE users; --now endsAS "sq_u""; DROP TABLE users; --", one alias, as it is everywhere else in the query; with the fix above alone, it endedAS "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 keyx" || ') 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
aliasx" ("result") GROUP BY "result, withcolumns: ['email'], returned one user, and now returns them all.
- Before this fix, a lateral key could read other tables:
- 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""breads as a hand-escapeda"b, so the lateral keysa"banda""bgiven together get the same alias, which Postgres refuses (42712).
- Each is one identifier there, any
- 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"ed 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 theaggregateofselect()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",UESCAPEclauses 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 thealiasofselect(), they now run as one alias, as does"u" AS "v".
- Well-formed: a quoted identifier, then any quoted,
- A plain value in the
updateValuesofupsert()is now sent as a parameter, as its type andupdate()already said.{ mood: 'happy' }compiles toDO UPDATE SET ("mood") = ROW($n), instead ofROW("happy"), an identifier.- Under 6.6.1, such a string could close its quotes:
xmin" || (SELECT string_agg(email::text, ',') FROM users) || "xminwrote every user's email into the column. - Under 6.6.1, a number, a
bigintor a boolean threw "Alien object",nullaTypeError, an object or a class instance (aDate, aBuffer, a custom type withtoPostgres()) became aWHERE-style condition of its keys (TRUEwithout keys), and an array its items one after the other (['a', 'b']gave the identifier"a""b",[1, 2]threw,[]gaveROW(), a syntax error): each is now a parameter, with the same JSON casts as inupdate(). - A value that holds a
sql,param(),raw(),cols(),vals()orparent()object,Defaultorself, 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 whatmapWithSeparator()returns when it holds a fragment (asqlseparator, say) or aparam(); 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 andEXCLUDED, 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 (
{}, aDate, an emptyBuffer, aMap, aSet), stored astrue; - an object or a class instance whose keys all name such a column (
{ 'users.display_name': 'Bob' },{ xmin: '1' }), stored astrueorfalse; - 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.
- a qualified column (
- 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 insidedb.sql`${value}`byte for byte as it did inupdateValuesunder 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 theSQLtype ofsqlrefusesdb.sql`${'films.title'}`(TS2345), in Zapatos 6.6.1 too.