@vantreeseba/drizzle-graphql 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -405,6 +405,53 @@ supplies no `orderBy`:
405
405
  An explicit `orderBy` always takes precedence, composite primary keys are ordered by every
406
406
  key column, and an unpaginated list query is left unordered so no sort is paid for.
407
407
 
408
+ ## Cursor pagination
409
+
410
+ `OFFSET` pagination degrades linearly and shifts under concurrent writes. List queries also
411
+ support keyset pagination: every row returned by a list query exposes an opaque `cursor`
412
+ field, and passing it back as `after` resumes strictly after that row.
413
+
414
+ ```graphql
415
+ {
416
+ posts(orderBy: { createdAt: { direction: desc, priority: 1 } }, limit: 10) {
417
+ id
418
+ createdAt
419
+ cursor
420
+ }
421
+ }
422
+
423
+ # next page — same orderBy, plus the last row's cursor
424
+ {
425
+ posts(
426
+ orderBy: { createdAt: { direction: desc, priority: 1 } }
427
+ limit: 10
428
+ after: "eyJvIjogW1siY3JlYXRlZEF0IiwgImRlc2MiXSwgWyJpZCIsICJhc2MiXV0sIC4uLn0"
429
+ ) {
430
+ id
431
+ createdAt
432
+ cursor
433
+ }
434
+ }
435
+ ```
436
+
437
+ - The cursor encodes the row's position in the query's **total order** — your `orderBy`
438
+ columns plus the primary key as an ascending tiebreak — so pages are stable even when
439
+ the ordered columns aren't unique, and inserts or deletes mid-scroll don't shift the
440
+ window
441
+ - `after` compiles to a keyset predicate built from drizzle's `and`/`or`/`gt`/`lt`
442
+ comparisons (not SQL row-value syntax), so mixed-direction `orderBy` works
443
+ - `NULL`s in ordered columns follow each dialect's native `ORDER BY` placement —
444
+ PostgreSQL sorts them largest (last in `asc`), MySQL and SQLite smallest (first in
445
+ `asc`) — and the predicate matches, so rows with `NULL` in an ordered column are paged
446
+ through, not skipped
447
+ - Dates and bigints round-trip losslessly through the cursor
448
+ - A cursor is only valid under the ordering it was issued for — reusing it with a
449
+ different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
450
+ - `after` cannot be combined with `distinct`, and a table with no primary key cannot use
451
+ cursor pagination (its `cursor` field resolves to `null`)
452
+ - If a table has a real column named `cursor`, the column wins and the meta field is
453
+ skipped
454
+
408
455
  ## Upsert
409
456
 
410
457
  `features.upsert` adds a pair of mutations per table that insert rows, or update the ones
package/dist/README.md CHANGED
@@ -405,6 +405,53 @@ supplies no `orderBy`:
405
405
  An explicit `orderBy` always takes precedence, composite primary keys are ordered by every
406
406
  key column, and an unpaginated list query is left unordered so no sort is paid for.
407
407
 
408
+ ## Cursor pagination
409
+
410
+ `OFFSET` pagination degrades linearly and shifts under concurrent writes. List queries also
411
+ support keyset pagination: every row returned by a list query exposes an opaque `cursor`
412
+ field, and passing it back as `after` resumes strictly after that row.
413
+
414
+ ```graphql
415
+ {
416
+ posts(orderBy: { createdAt: { direction: desc, priority: 1 } }, limit: 10) {
417
+ id
418
+ createdAt
419
+ cursor
420
+ }
421
+ }
422
+
423
+ # next page — same orderBy, plus the last row's cursor
424
+ {
425
+ posts(
426
+ orderBy: { createdAt: { direction: desc, priority: 1 } }
427
+ limit: 10
428
+ after: "eyJvIjogW1siY3JlYXRlZEF0IiwgImRlc2MiXSwgWyJpZCIsICJhc2MiXV0sIC4uLn0"
429
+ ) {
430
+ id
431
+ createdAt
432
+ cursor
433
+ }
434
+ }
435
+ ```
436
+
437
+ - The cursor encodes the row's position in the query's **total order** — your `orderBy`
438
+ columns plus the primary key as an ascending tiebreak — so pages are stable even when
439
+ the ordered columns aren't unique, and inserts or deletes mid-scroll don't shift the
440
+ window
441
+ - `after` compiles to a keyset predicate built from drizzle's `and`/`or`/`gt`/`lt`
442
+ comparisons (not SQL row-value syntax), so mixed-direction `orderBy` works
443
+ - `NULL`s in ordered columns follow each dialect's native `ORDER BY` placement —
444
+ PostgreSQL sorts them largest (last in `asc`), MySQL and SQLite smallest (first in
445
+ `asc`) — and the predicate matches, so rows with `NULL` in an ordered column are paged
446
+ through, not skipped
447
+ - Dates and bigints round-trip losslessly through the cursor
448
+ - A cursor is only valid under the ordering it was issued for — reusing it with a
449
+ different `orderBy` (or a malformed/corrupted cursor) returns a `GraphQLError`
450
+ - `after` cannot be combined with `distinct`, and a table with no primary key cannot use
451
+ cursor pagination (its `cursor` field resolves to `null`)
452
+ - If a table has a real column named `cursor`, the column wins and the meta field is
453
+ skipped
454
+
408
455
  ## Upsert
409
456
 
410
457
  `features.upsert` adds a pair of mutations per table that insert rows, or update the ones
package/dist/index.cjs CHANGED
@@ -1013,6 +1013,13 @@ var generateTableSelectTypeFieldsCached = (table, tableName) => {
1013
1013
  drizzleColumnToGraphQLType(columnDescription, columnName, tableName)
1014
1014
  ])
1015
1015
  );
1016
+ if (!remapped[CURSOR_FIELD_NAME]) {
1017
+ remapped[CURSOR_FIELD_NAME] = {
1018
+ type: import_graphql4.GraphQLString,
1019
+ description: "Opaque cursor of this row's position in the query's ordering. Pass it as `after` to resume from here. Only set on rows returned by a list query.",
1020
+ resolve: rowCursorResolver
1021
+ };
1022
+ }
1016
1023
  fieldMap.set(table, remapped);
1017
1024
  return remapped;
1018
1025
  };
@@ -1840,6 +1847,117 @@ var primaryKeyOrderExprs = (table, pkNames) => {
1840
1847
  const cols = (0, import_drizzle_orm3.getColumns)(table);
1841
1848
  return pkNames.map((n) => cols[n]).filter(Boolean).map((col) => (0, import_drizzle_orm3.asc)(col));
1842
1849
  };
1850
+ var CURSOR_FIELD_NAME = "cursor";
1851
+ var ROW_CURSOR_PROP = "__drizzle_graphql_cursor";
1852
+ var orderByHasRelationEntry = (orderBy) => !!orderBy && Object.values(orderBy).some((config) => config && typeof config === "object" && config.direction === void 0);
1853
+ var cursorOrderingEntries = (orderBy, pkNames) => {
1854
+ const entries = orderBy ? orderByEntries(orderBy) : [];
1855
+ const seen = new Set(entries.map(([column]) => column));
1856
+ for (const pk of pkNames) {
1857
+ if (!seen.has(pk)) {
1858
+ entries.push([pk, "asc"]);
1859
+ }
1860
+ }
1861
+ return entries;
1862
+ };
1863
+ var cursorOrderExprs = (table, entries) => {
1864
+ const cols = (0, import_drizzle_orm3.getColumns)(table);
1865
+ return entries.flatMap(
1866
+ ([column, direction, nulls]) => orderExpressions(cols[column], direction, nulls ?? void 0, cols[column])
1867
+ );
1868
+ };
1869
+ var encodeCursorValue = (value) => {
1870
+ if (value instanceof Date) {
1871
+ return { $type: "date", value: value.toISOString() };
1872
+ }
1873
+ if (typeof value === "bigint") {
1874
+ return { $type: "bigint", value: value.toString() };
1875
+ }
1876
+ return value;
1877
+ };
1878
+ var decodeCursorValue = (value) => {
1879
+ if (value && typeof value === "object" && typeof value.$type === "string") {
1880
+ if (value.$type === "date") {
1881
+ return new Date(value.value);
1882
+ }
1883
+ if (value.$type === "bigint") {
1884
+ return BigInt(value.value);
1885
+ }
1886
+ }
1887
+ return value;
1888
+ };
1889
+ var encodeCursor = (entries, row) => {
1890
+ const payload = {
1891
+ o: entries,
1892
+ v: entries.map(([column]) => encodeCursorValue(row[column] ?? null))
1893
+ };
1894
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
1895
+ };
1896
+ var decodeCursor = (after, entries) => {
1897
+ let payload;
1898
+ try {
1899
+ payload = JSON.parse(Buffer.from(after, "base64url").toString("utf8"));
1900
+ } catch (_e) {
1901
+ throw new import_graphql4.GraphQLError("Invalid cursor: unable to decode it. Pass a cursor returned by a previous page.");
1902
+ }
1903
+ if (!payload || typeof payload !== "object" || !Array.isArray(payload.o) || !Array.isArray(payload.v) || payload.o.length !== payload.v.length) {
1904
+ throw new import_graphql4.GraphQLError("Invalid cursor: malformed payload. Pass a cursor returned by a previous page.");
1905
+ }
1906
+ const matchesOrdering = payload.o.length === entries.length && payload.o.every(
1907
+ (entry, i) => Array.isArray(entry) && entry[0] === entries[i][0] && entry[1] === entries[i][1] && // The nulls override changes where NULLs sort, so it is part of the ordering identity.
1908
+ // JSON round-trips undefined as null — compare the normalized forms.
1909
+ (entry[2] ?? null) === (entries[i][2] ?? null)
1910
+ );
1911
+ if (!matchesOrdering) {
1912
+ throw new import_graphql4.GraphQLError(
1913
+ "Invalid cursor: it was issued for a different ordering. Pass the same orderBy the cursor's page used."
1914
+ );
1915
+ }
1916
+ return payload.v.map(decodeCursorValue);
1917
+ };
1918
+ var buildCursorCondition = (table, entries, values, nullOrdering) => {
1919
+ const cols = (0, import_drizzle_orm3.getColumns)(table);
1920
+ const disjuncts = [];
1921
+ const equalities = [];
1922
+ entries.forEach(([column, direction, nulls], i) => {
1923
+ const col = cols[column];
1924
+ if (!col) {
1925
+ throw new import_graphql4.GraphQLError(`Invalid cursor: unknown column '${column}'.`);
1926
+ }
1927
+ const value = values[i];
1928
+ const nullsSortLast = nulls != null ? nulls === "last" : direction === "asc" === (nullOrdering === "nulls-largest");
1929
+ let strictlyAfter;
1930
+ if (value === null || value === void 0) {
1931
+ strictlyAfter = nullsSortLast ? void 0 : (0, import_drizzle_orm3.isNotNull)(col);
1932
+ } else {
1933
+ const comparison = direction === "asc" ? (0, import_drizzle_orm3.gt)(col, value) : (0, import_drizzle_orm3.lt)(col, value);
1934
+ strictlyAfter = nullsSortLast ? (0, import_drizzle_orm3.or)(comparison, (0, import_drizzle_orm3.isNull)(col)) : comparison;
1935
+ }
1936
+ if (strictlyAfter) {
1937
+ disjuncts.push(equalities.length ? (0, import_drizzle_orm3.and)(...equalities, strictlyAfter) : strictlyAfter);
1938
+ }
1939
+ equalities.push(value === null || value === void 0 ? (0, import_drizzle_orm3.isNull)(col) : (0, import_drizzle_orm3.eq)(col, value));
1940
+ });
1941
+ if (!disjuncts.length) {
1942
+ return import_drizzle_orm3.sql`1 = 0`;
1943
+ }
1944
+ return disjuncts.length > 1 ? (0, import_drizzle_orm3.or)(...disjuncts) : disjuncts[0];
1945
+ };
1946
+ var isCursorFieldSelected = (tree, table) => {
1947
+ if (!tree) {
1948
+ return false;
1949
+ }
1950
+ if ((0, import_drizzle_orm3.getColumns)(table)[CURSOR_FIELD_NAME]) {
1951
+ return false;
1952
+ }
1953
+ return Object.values(tree).some((field) => field.name === CURSOR_FIELD_NAME);
1954
+ };
1955
+ var attachRowCursors = (rows, entries) => {
1956
+ for (const row of rows) {
1957
+ row[ROW_CURSOR_PROP] = encodeCursor(entries, row);
1958
+ }
1959
+ };
1960
+ var rowCursorResolver = (source) => source?.[ROW_CURSOR_PROP] ?? null;
1843
1961
  var getUniqueColumnSets = (table, getTableConfig4) => {
1844
1962
  const cols = (0, import_drizzle_orm3.getColumns)(table);
1845
1963
  const propNameByColumnName = new Map(Object.entries(cols).map(([propName, col]) => [col.name, propName]));
@@ -2164,6 +2282,10 @@ var selectArrayArgs = (orderArgs, filterArgs, distinctEnum) => ({
2164
2282
  limit: { type: import_graphql4.GraphQLInt },
2165
2283
  orderBy: { type: orderArgs },
2166
2284
  where: { type: filterArgs },
2285
+ after: {
2286
+ type: import_graphql4.GraphQLString,
2287
+ description: "Keyset pagination: only return rows strictly after this cursor (a row's `cursor` field from a previous page, under the same orderBy)."
2288
+ },
2167
2289
  ...distinctEnum ? { distinct: { type: new import_graphql4.GraphQLList(new import_graphql4.GraphQLNonNull(distinctEnum)) } } : {}
2168
2290
  });
2169
2291
  var selectSingleArgs = (orderArgs, filterArgs) => ({
@@ -2186,9 +2308,31 @@ var runRelationalSelect = async (opts) => {
2186
2308
  where,
2187
2309
  single,
2188
2310
  filterCtx,
2189
- pkNames
2311
+ pkNames,
2312
+ after
2190
2313
  } = opts;
2191
2314
  const distinct = opts.distinct?.length ? opts.distinct : void 0;
2315
+ const cursorSelected = !single && isCursorFieldSelected(parsedInfo.fieldsByTypeName[typeName], table);
2316
+ let cursorEntries;
2317
+ if (!single && (after != null || cursorSelected)) {
2318
+ if (after != null && distinct) {
2319
+ throw new import_graphql4.GraphQLError("'after' cannot be combined with 'distinct'.");
2320
+ }
2321
+ if (orderByHasRelationEntry(orderBy)) {
2322
+ if (after != null) {
2323
+ throw new import_graphql4.GraphQLError(
2324
+ "'after' cannot be combined with an orderBy that orders through a relation \u2014 a related row's value cannot be encoded into a cursor."
2325
+ );
2326
+ }
2327
+ } else if (!pkNames?.length) {
2328
+ if (after != null) {
2329
+ throw new import_graphql4.GraphQLError(`Table ${tableName} has no primary key, so cursor pagination cannot be used on it.`);
2330
+ }
2331
+ } else {
2332
+ cursorEntries = cursorOrderingEntries(orderBy, pkNames);
2333
+ }
2334
+ }
2335
+ const cursorValues = after != null && cursorEntries ? decodeCursor(after, cursorEntries) : void 0;
2192
2336
  const needsDefaultOrder = single || offset != null || opts.limit != null;
2193
2337
  let distinctKeys;
2194
2338
  if (distinct) {
@@ -2219,9 +2363,21 @@ var runRelationalSelect = async (opts) => {
2219
2363
  orderBy: distinctKeys ? (aliasedTable2) => [
2220
2364
  ...orderBy ? extractOrderBy(aliasedTable2, orderBy, relationFilterCtx(filterCtx, tableName)) : [],
2221
2365
  ...primaryKeyOrderExprs(aliasedTable2, pkNames)
2222
- ] : orderBy ? (aliasedTable2) => extractOrderBy(aliasedTable2, orderBy, relationFilterCtx(filterCtx, tableName)) : needsDefaultOrder && pkNames?.length ? (aliasedTable2) => primaryKeyOrderExprs(aliasedTable2, pkNames) : void 0,
2223
- where: distinctKeys ? { RAW: (aliasedTable2) => primaryKeyRestriction(aliasedTable2, pkNames, distinctKeys) } : where ? {
2224
- RAW: (aliasedTable2) => extractFilters(aliasedTable2, tableName, where, relationFilterCtx(filterCtx, tableName))
2366
+ ] : cursorEntries ? (
2367
+ // Cursor pagination needs a total order: the request's orderBy plus the PK tiebreak,
2368
+ // exactly the ordering the cursor encodes and the keyset predicate compares against.
2369
+ (aliasedTable2) => cursorOrderExprs(aliasedTable2, cursorEntries)
2370
+ ) : orderBy ? (aliasedTable2) => extractOrderBy(aliasedTable2, orderBy, relationFilterCtx(filterCtx, tableName)) : needsDefaultOrder && pkNames?.length ? (aliasedTable2) => primaryKeyOrderExprs(aliasedTable2, pkNames) : void 0,
2371
+ where: distinctKeys ? { RAW: (aliasedTable2) => primaryKeyRestriction(aliasedTable2, pkNames, distinctKeys) } : where || cursorValues ? {
2372
+ RAW: (aliasedTable2) => (0, import_drizzle_orm3.and)(
2373
+ where ? extractFilters(aliasedTable2, tableName, where, relationFilterCtx(filterCtx, tableName)) : void 0,
2374
+ cursorValues ? buildCursorCondition(
2375
+ aliasedTable2,
2376
+ cursorEntries,
2377
+ cursorValues,
2378
+ opts.nullOrdering ?? "nulls-smallest"
2379
+ ) : void 0
2380
+ )
2225
2381
  } : void 0,
2226
2382
  with: relationMap[tableName] ? extractRelationsParams(relationMap, tables, tableName, parsedInfo, typeName, typeNameMapper, filterCtx) : void 0
2227
2383
  };
@@ -2229,8 +2385,16 @@ var runRelationalSelect = async (opts) => {
2229
2385
  const result2 = await queryBase.findFirst(params);
2230
2386
  return result2 ? remapToGraphQLSingleOutput(result2, tableName, table, relationMap) : void 0;
2231
2387
  }
2388
+ if (cursorEntries && cursorSelected) {
2389
+ for (const [column] of cursorEntries) {
2390
+ params.columns[column] = true;
2391
+ }
2392
+ }
2232
2393
  params.limit = distinctKeys ? void 0 : opts.limit;
2233
2394
  const result = await queryBase.findMany(params);
2395
+ if (cursorEntries && cursorSelected) {
2396
+ attachRowCursors(result, cursorEntries);
2397
+ }
2234
2398
  return remapToGraphQLArrayOutput(result, tableName, table, relationMap);
2235
2399
  };
2236
2400
  var eagerLoadMutationRelations = async (db, tableName, rows, pkNames, withParams) => {
@@ -2778,7 +2942,9 @@ var generateSelectArray = (db, tableName, tables, relationMap, orderArgs, filter
2778
2942
  single: false,
2779
2943
  filterCtx,
2780
2944
  pkNames,
2781
- db: executor
2945
+ db: executor,
2946
+ // MySQL sorts NULLs as the smallest values (first in ASC).
2947
+ nullOrdering: "nulls-smallest"
2782
2948
  });
2783
2949
  } catch (e) {
2784
2950
  throw toGraphQLError(e);
@@ -3276,16 +3442,48 @@ var generateSelectArray2 = (db, tableName, tables, relationMap, orderArgs, filte
3276
3442
  single: false,
3277
3443
  filterCtx,
3278
3444
  pkNames,
3279
- db: executor
3445
+ db: executor,
3446
+ // PostgreSQL sorts NULLs as the largest values (last in ASC).
3447
+ nullOrdering: "nulls-largest"
3280
3448
  });
3281
3449
  }
3282
- const { offset, limit, orderBy, where, distinct } = args;
3450
+ const { offset, limit, orderBy, where, distinct, after } = args;
3283
3451
  const selectedColumnsSql = extractSelectedColumnsFromTreeSQLFormat(
3284
3452
  parsedInfo.fieldsByTypeName[typeName],
3285
3453
  table,
3286
3454
  { tableName, relationMap, tables }
3287
3455
  );
3288
- const whereSql = where ? extractFilters(table, tableName, where, relationFilterCtx(filterCtx, tableName)) : void 0;
3456
+ const cursorSelected = isCursorFieldSelected(parsedInfo.fieldsByTypeName[typeName], table);
3457
+ let cursorEntries;
3458
+ if (after != null || cursorSelected) {
3459
+ if (after != null && distinct?.length) {
3460
+ throw new import_graphql7.GraphQLError("'after' cannot be combined with 'distinct'.");
3461
+ }
3462
+ if (orderByHasRelationEntry(orderBy)) {
3463
+ if (after != null) {
3464
+ throw new import_graphql7.GraphQLError(
3465
+ "'after' cannot be combined with an orderBy that orders through a relation \u2014 a related row's value cannot be encoded into a cursor."
3466
+ );
3467
+ }
3468
+ } else if (!pkNames.length) {
3469
+ if (after != null) {
3470
+ throw new import_graphql7.GraphQLError(
3471
+ `Table ${tableName} has no primary key, so cursor pagination cannot be used on it.`
3472
+ );
3473
+ }
3474
+ } else {
3475
+ cursorEntries = cursorOrderingEntries(orderBy, pkNames);
3476
+ if (cursorSelected) {
3477
+ const allColumns = (0, import_drizzle_orm6.getColumns)(table);
3478
+ for (const [column] of cursorEntries) {
3479
+ selectedColumnsSql[column] ??= allColumns[column];
3480
+ }
3481
+ }
3482
+ }
3483
+ }
3484
+ const cursorCondition = after != null && cursorEntries ? buildCursorCondition(table, cursorEntries, decodeCursor(after, cursorEntries), "nulls-largest") : void 0;
3485
+ const baseWhereSql = where ? extractFilters(table, tableName, where, relationFilterCtx(filterCtx, tableName)) : void 0;
3486
+ const whereSql = cursorCondition ? (0, import_drizzle_orm6.and)(baseWhereSql, cursorCondition) : baseWhereSql;
3289
3487
  let distinctKeys;
3290
3488
  if (distinct?.length) {
3291
3489
  distinctKeys = await selectDistinctKeys({
@@ -3309,7 +3507,9 @@ var generateSelectArray2 = (db, tableName, tables, relationMap, orderArgs, filte
3309
3507
  } else if (whereSql) {
3310
3508
  q = q.where(whereSql);
3311
3509
  }
3312
- if (orderBy) {
3510
+ if (cursorEntries && !distinctKeys) {
3511
+ q = q.orderBy(...cursorOrderExprs(table, cursorEntries));
3512
+ } else if (orderBy) {
3313
3513
  q = q.orderBy(
3314
3514
  ...extractOrderBy(table, orderBy, relationFilterCtx(filterCtx, tableName)),
3315
3515
  ...distinctKeys ? primaryKeyOrderExprs(table, pkNames) : []
@@ -3325,7 +3525,11 @@ var generateSelectArray2 = (db, tableName, tables, relationMap, orderArgs, filte
3325
3525
  q = q.limit(limit);
3326
3526
  }
3327
3527
  }
3328
- return remapToGraphQLArrayOutput(await q, tableName, table, relationMap);
3528
+ const rows = await q;
3529
+ if (cursorEntries && cursorSelected) {
3530
+ attachRowCursors(rows, cursorEntries);
3531
+ }
3532
+ return remapToGraphQLArrayOutput(rows, tableName, table, relationMap);
3329
3533
  } catch (e) {
3330
3534
  throw toGraphQLError(e);
3331
3535
  }
@@ -4037,7 +4241,9 @@ var generateSelectArray3 = (db, tableName, tables, relationMap, orderArgs, filte
4037
4241
  single: false,
4038
4242
  filterCtx,
4039
4243
  pkNames,
4040
- db: executor
4244
+ db: executor,
4245
+ // SQLite sorts NULLs as the smallest values (first in ASC).
4246
+ nullOrdering: "nulls-smallest"
4041
4247
  });
4042
4248
  } catch (e) {
4043
4249
  throw toGraphQLError(e);