pagi-help@2.5.0 ships two APIs from one package.
require("pagi-help")keeps the frozen legacy MySQL contract.require("pagi-help/v2")is the current hardened API for new code.
npm install pagi-helpNew code:
const PagiHelpV2 = require("pagi-help/v2");Legacy compatibility:
const PagiHelp = require("pagi-help");Named exports are also available:
const {
PagiHelpLegacy,
PagiHelpV2,
PagiHelpV210,
} = require("pagi-help");PagiHelpV210 remains a compatibility alias. New code should use PagiHelpV2.
const pagiHelp = new PagiHelpV2({
dialect: "mysql", // default
columnNameConverter: (name) =>
name.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`),
safeOptions: {
validate: true,
},
});v2 constructor rules:
dialectmay be"mysql"or"postgres"- omitted
dialectdefaults to"mysql" safeOptions.validateis the only supportedsafeOptionskey- legacy compatibility toggles are intentionally rejected on
v2
The legacy default export does not gain dialect support. It remains the old MySQL implementation.
const PagiHelpV2 = require("pagi-help/v2");
const pagiHelp = new PagiHelpV2({
dialect: "mysql",
});
const queries = pagiHelp.paginate(
{
search: "Active",
filters: [["status", "IN", ["Active", "Paused"]]],
sort: {
attributes: ["created_at"],
sorts: ["desc"],
},
pageNo: 1,
itemsPerPage: 10,
},
[
{
tableName: "events",
columnList: [
{ name: "id", alias: "id" },
{ name: "status", alias: "status" },
{ name: "created_at", alias: "created_at" },
],
searchColumnList: [{ name: "status" }],
},
]
);MySQL pagination clause:
LIMIT ?,?Replacements are [offset, limit].
const PagiHelpV2 = require("pagi-help/v2");
const pagiHelp = new PagiHelpV2({
dialect: "postgres",
});
const queries = pagiHelp.paginate(
{
search: "mail",
filters: [
["metaInfo", "@>", { priority: "high" }],
["tags", "?|", ["vip", "priority"]],
["email", "~*", "@example\\.com$"],
],
sort: {
attributes: ["createdAt"],
sorts: ["desc"],
},
pageNo: 2,
itemsPerPage: 10,
},
[
{
tableName: "audit.licenses",
columnList: [
{ name: "license_id", alias: "id" },
{ name: "created_at", alias: "createdAt" },
{ name: "meta_info", alias: "metaInfo" },
{ name: "tags", alias: "tags" },
{
statement:
"(CASE WHEN audit.licenses.assigned_to = '1' THEN 'Yes' ELSE 'No' END)",
alias: "assignedToMe",
},
],
searchColumnList: [{ name: "created_at" }],
additionalWhereConditions: [["audit.licenses.organization_id", "=", 42]],
},
]
);PostgreSQL pagination clause:
LIMIT ? OFFSET ?Replacements are [limit, offset].
Use PostgreSQL SQL inside statement, joinQuery, and raw additionalWhereConditions. Do not reuse MySQL-only functions like IF() there.
Schema-qualified PostgreSQL names are supported on v2:
tableName: "audit.licenses"rendersFROM "audit"."licenses"- if you want an alias, keep it in
joinQuery, not insidetableName - raw
additionalWhereConditionscan use fully-qualified fields like"audit.licenses.organization_id" - regular filters still resolve by alias or
prefix.column, not byschema.table.column
paginateCursor() is available on v2 only.
Phase 1 rules:
- single-table only
afteronlysortis requiredlimitis requiredpageNo,itemsPerPage,offset, andbeforeare rejected- the selected columns must include alias
id
const PagiHelpV2 = require("pagi-help/v2");
const pagiHelp = new PagiHelpV2({
dialect: "postgres",
});
const cursorQueries = pagiHelp.paginateCursor(
{
search: "mail",
filters: [["stage", "=", "OPEN"]],
sort: {
attributes: ["createdAt"],
sorts: ["desc"],
},
limit: 20,
after: existingCursorToken,
},
[
{
tableName: "audit.licenses",
columnList: [
{ name: "license_id", alias: "id" },
{ name: "created_at", alias: "createdAt" },
{ name: "stage", alias: "stage" },
],
searchColumnList: [{ name: "stage" }],
},
]
);Cursor helpers:
paginateCursor()returns SQL pluscursorPlanqueryfetcheslimit + 1rowsresolveCursorPage(rows, cursorPlan)trims the extra row and returnspageInfoencodeCursorFromRow(row, cursorPlan)builds an opaqueaftertokendecodeCursor(token)decodes and validates the token envelope
Cursor return shape:
{
countQuery,
totalCountQuery,
query,
replacements,
cursorPlan
}Important cursor semantics:
countQueryandtotalCountQueryremain aggregate onv2- when
afteris present, both count queries include the cursor predicate - MySQL cursor pagination uses
LIMIT ?,?with replacements[0, limit + 1] - PostgreSQL cursor pagination uses
LIMIT ? OFFSET ?with replacements[limit + 1, 0]
Both APIs return:
{
countQuery,
totalCountQuery,
query,
replacements
}Key semantic difference:
v2countQueryis aggregate- legacy
countQueryis still a row-select query totalCountQueryremains aggregate in both paths
Compared with the legacy export, v2:
- stops emitting dangling
WHERE - stops turning missing
searchinto%undefined% - stops mutating caller sort arrays
- stops logging replacements by default
- makes
countQueryaggregate - rejects
aliasinsearchColumnList - normalizes
joinQuery - treats missing
searchColumnListas[] - rejects empty
INarrays cleanly - throws
Errorobjects instead of string throws
Shared behavior:
- top-level
filtersare joined withAND - nested filter arrays become
ORgroups - tuples use
[field, operator, value] joinQuery,statement, and rawadditionalWhereConditionsare trusted-input-only SQL
Dialect-specific rendering on v2:
- MySQL quotes generated table and
ORDER BYidentifiers with backticks - PostgreSQL quotes generated table and
ORDER BYidentifiers with double quotes - MySQL keeps
JSON_CONTAINS,JSON_OVERLAPS,FIND_IN_SET,RLIKE, andMEMBER OFas MySQL SQL - PostgreSQL has its own native operator set on
v2:ILIKE~,~*,!~,!~*@>,<@?,?|,?&&&
- PostgreSQL also keeps compatibility aliases for shared-code migrations:
JSON_CONTAINS->@>JSON_OVERLAPS-> emulatedjsonboverlap SQLFIND_IN_SET->array_position(string_to_array(...), ?::text) IS NOT NULLRLIKE->~MEMBER OF->?::jsonb @> to_jsonb(field)! IN->NOT IN
AGENTS.md: repo-level instructions for Codex and other agentsdocs/AGENT_USAGE.md: agent-facing quick reference for currentv2docs/V2_BASELINE.md: maintainer contract for currentv2docs/V2_CURSOR_PAGINATION_DESIGN.md: implemented phase-1 cursor contract plus future roadmapdocs/MAINTENANCE_BASELINE.md: frozen legacy default-export contractdocs/legacy/README.md: legacy archive entrypointdocs/CONSUMER_USAGE_AUDIT.md: downstream legacy usage auditdocs/CONSUMER_USAGE_AUDIT_XLEY.md: second downstream legacy usage audittest/characterization.test.js: suite runnertest/mysql.characterization.test.js: legacy plus MySQLv2regression coveragetest/postgres.characterization.test.js: PostgreSQLv2regression coverageexamples/v2.js: MySQLv2exampleexamples/v2-postgres.js: PostgreSQLv2exampleexamples/v2-cursor.js:v2cursor pagination example
Before publishing, run:
npm run release:verify