metal-orm 1.1.8 → 1.1.9

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
@@ -291,7 +291,7 @@ const listOpenTodos = selectFrom(todos).select(...defaultColumns);
291
291
 
292
292
  That's it: schema, query, SQL, done.
293
293
 
294
- If you are using the Level 2 runtime (`OrmSession`), `SelectQueryBuilder` also provides `count(session)` and `executePaged(session, { page, pageSize })` for common pagination patterns.
294
+ If you are using the Level 2 runtime (`OrmSession`), `SelectQueryBuilder` also provides `count(session)`, `executePaged(session, { page, pageSize })`, and `executeCursor(session, { first/after | last/before })` for common pagination patterns. See [docs/pagination.md](./docs/pagination.md) for offset pagination, eager-include pagination guards, and bidirectional cursor pagination.
295
295
 
296
296
  #### Column pickers (preferred selection helpers)
297
297
 
package/dist/index.cjs CHANGED
@@ -7714,6 +7714,193 @@ function buildWhereHasPredicate(env, context, relationFacet, createChildBuilder,
7714
7714
  return negate ? notExists(finalSubAst) : exists(finalSubAst);
7715
7715
  }
7716
7716
 
7717
+ // src/query-builder/select/cursor-pagination.ts
7718
+ function encodeCursor(payload) {
7719
+ return Buffer.from(JSON.stringify(payload)).toString("base64url");
7720
+ }
7721
+ function decodeCursor(cursor) {
7722
+ let parsed;
7723
+ try {
7724
+ parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
7725
+ } catch {
7726
+ throw new Error("executeCursor: invalid cursor format");
7727
+ }
7728
+ if (typeof parsed !== "object" || parsed === null || parsed.v !== 2 || !Array.isArray(parsed.values) || typeof parsed.orderSig !== "string") {
7729
+ throw new Error("executeCursor: invalid cursor payload");
7730
+ }
7731
+ return parsed;
7732
+ }
7733
+ function buildOrderSignature(specs) {
7734
+ return specs.map((s) => `${s.table}.${s.column}:${s.direction}`).join(",");
7735
+ }
7736
+ function extractOrderSpecs(ast) {
7737
+ if (!ast.orderBy || ast.orderBy.length === 0) {
7738
+ throw new Error("executeCursor: ORDER BY is required for cursor pagination");
7739
+ }
7740
+ return ast.orderBy.map((ob) => {
7741
+ if (ob.nulls) {
7742
+ throw new Error("executeCursor: NULLS FIRST/LAST is not supported for cursor pagination");
7743
+ }
7744
+ const term = ob.term;
7745
+ if (!term || term.type !== "Column") {
7746
+ throw new Error(
7747
+ "executeCursor: only column references are supported in ORDER BY for cursor pagination"
7748
+ );
7749
+ }
7750
+ const col2 = term;
7751
+ return {
7752
+ table: col2.table,
7753
+ column: col2.name,
7754
+ valueKey: resolveOrderValueKey(ast, col2),
7755
+ direction: ob.direction
7756
+ };
7757
+ });
7758
+ }
7759
+ function resolveOrderValueKey(ast, col2) {
7760
+ const projectedColumn = ast.columns.find(
7761
+ (candidate) => candidate.type === "Column" && candidate.table === col2.table && candidate.name === col2.name
7762
+ );
7763
+ return projectedColumn?.alias ?? projectedColumn?.name ?? col2.alias ?? col2.name;
7764
+ }
7765
+ function buildKeysetPredicate(specs, values, mode) {
7766
+ if (values.length !== specs.length) {
7767
+ throw new Error("executeCursor: invalid cursor payload");
7768
+ }
7769
+ const branches = [];
7770
+ for (let i = 0; i < specs.length; i++) {
7771
+ const spec = specs[i];
7772
+ const colNode = { type: "Column", table: spec.table, name: spec.column };
7773
+ const value = values[i];
7774
+ if (value === null || value === void 0) {
7775
+ throw new Error("executeCursor: invalid cursor payload");
7776
+ }
7777
+ const literal = { type: "Literal", value };
7778
+ let operator;
7779
+ if (mode === "after") {
7780
+ operator = spec.direction === "ASC" ? ">" : "<";
7781
+ } else {
7782
+ operator = spec.direction === "ASC" ? "<" : ">";
7783
+ }
7784
+ const eqParts = [];
7785
+ for (let j = 0; j < i; j++) {
7786
+ const prevSpec = specs[j];
7787
+ const prevCol = { type: "Column", table: prevSpec.table, name: prevSpec.column };
7788
+ const prevValue = values[j];
7789
+ if (prevValue === null || prevValue === void 0) {
7790
+ throw new Error("executeCursor: invalid cursor payload");
7791
+ }
7792
+ const prevVal = { type: "Literal", value: prevValue };
7793
+ eqParts.push({
7794
+ type: "BinaryExpression",
7795
+ left: prevCol,
7796
+ operator: "=",
7797
+ right: prevVal
7798
+ });
7799
+ }
7800
+ const breakExpr = {
7801
+ type: "BinaryExpression",
7802
+ left: colNode,
7803
+ operator,
7804
+ right: literal
7805
+ };
7806
+ if (eqParts.length === 0) {
7807
+ branches.push(breakExpr);
7808
+ } else {
7809
+ branches.push(and(...eqParts, breakExpr));
7810
+ }
7811
+ }
7812
+ return branches.length === 1 ? branches[0] : or(...branches);
7813
+ }
7814
+ function buildCursorFromRow(row, specs) {
7815
+ const values = specs.map((spec) => {
7816
+ const value = row[spec.valueKey];
7817
+ if (value === null || value === void 0) {
7818
+ throw new Error("executeCursor: cursor pagination requires non-null ORDER BY values");
7819
+ }
7820
+ return value;
7821
+ });
7822
+ return encodeCursor({ v: 2, values, orderSig: buildOrderSignature(specs) });
7823
+ }
7824
+ function reverseDirection(direction) {
7825
+ return direction === "ASC" ? "DESC" : "ASC";
7826
+ }
7827
+ function createExecutionBuilder(builder, options) {
7828
+ const internals = builder.getInternals();
7829
+ const baseAst = internals.context.state.ast;
7830
+ const orderBy = options.reverseOrder && baseAst.orderBy ? baseAst.orderBy.map((order) => ({
7831
+ ...order,
7832
+ direction: reverseDirection(order.direction)
7833
+ })) : baseAst.orderBy;
7834
+ const nextAst = {
7835
+ ...baseAst,
7836
+ where: options.predicate ? baseAst.where ? and(baseAst.where, options.predicate) : options.predicate : baseAst.where,
7837
+ orderBy,
7838
+ limit: options.limit
7839
+ };
7840
+ const nextContext = {
7841
+ ...internals.context,
7842
+ state: new SelectQueryState(builder.getTable(), nextAst)
7843
+ };
7844
+ return internals.clone(nextContext, internals.includeTree);
7845
+ }
7846
+ async function executeCursorQuery(builder, session, options) {
7847
+ const { first, after, last, before } = options;
7848
+ if (first != null && last != null) {
7849
+ throw new Error('executeCursor: "first" and "last" cannot be used together');
7850
+ }
7851
+ if (after != null && before != null) {
7852
+ throw new Error('executeCursor: "after" and "before" cannot be used together');
7853
+ }
7854
+ if (first == null && last == null) {
7855
+ throw new Error('executeCursor: either "first" or "last" must be provided');
7856
+ }
7857
+ const limit = first ?? last;
7858
+ if (!Number.isInteger(limit) || limit < 1) {
7859
+ throw new Error(`executeCursor: "${first != null ? "first" : "last"}" must be an integer >= 1`);
7860
+ }
7861
+ const isBackward = last != null;
7862
+ const cursor = after ?? before;
7863
+ const ast = builder.getInternals().context.state.ast;
7864
+ const specs = extractOrderSpecs(ast);
7865
+ let predicate;
7866
+ if (cursor) {
7867
+ const decoded = decodeCursor(cursor);
7868
+ const expectedSig = buildOrderSignature(specs);
7869
+ if (decoded.orderSig !== expectedSig) {
7870
+ throw new Error(
7871
+ "executeCursor: cursor ORDER BY signature does not match the current query. The ORDER BY clause must remain the same between paginated requests."
7872
+ );
7873
+ }
7874
+ predicate = buildKeysetPredicate(specs, decoded.values, isBackward ? "before" : "after");
7875
+ }
7876
+ const executionBuilder = createExecutionBuilder(builder, {
7877
+ predicate,
7878
+ limit: limit + 1,
7879
+ reverseOrder: isBackward
7880
+ });
7881
+ const rows = await executionBuilder.execute(session);
7882
+ const hasExtraItem = rows.length > limit;
7883
+ if (hasExtraItem) {
7884
+ rows.pop();
7885
+ }
7886
+ const orderedRows = isBackward ? rows.reverse() : rows;
7887
+ const items = orderedRows;
7888
+ const hasItems = items.length > 0;
7889
+ const hasNextPage2 = hasItems ? isBackward ? before != null : hasExtraItem : false;
7890
+ const hasPreviousPage = hasItems ? isBackward ? hasExtraItem : after != null : false;
7891
+ const startCursor = hasItems ? buildCursorFromRow(items[0], specs) : null;
7892
+ const endCursor = hasItems ? buildCursorFromRow(items[items.length - 1], specs) : null;
7893
+ return {
7894
+ items,
7895
+ pageInfo: {
7896
+ hasNextPage: hasNextPage2,
7897
+ hasPreviousPage,
7898
+ startCursor,
7899
+ endCursor
7900
+ }
7901
+ };
7902
+ }
7903
+
7717
7904
  // src/query-builder/select/from-facet.ts
7718
7905
  var SelectFromFacet = class {
7719
7906
  /**
@@ -8740,7 +8927,38 @@ var SelectQueryBuilder = class _SelectQueryBuilder {
8740
8927
  return executePagedQuery(builder, session, options, (sess) => builder.count(sess));
8741
8928
  }
8742
8929
  /**
8743
- * Executes the query and returns an array of values for a single column.
8930
+ * Executes the query using cursor-based (keyset) pagination.
8931
+ * Requires a stable ORDER BY on selected, non-null columns.
8932
+ * Cursor pagination currently supports simple column references only and
8933
+ * the cursor token is opaque: it must be reused with the same ORDER BY signature.
8934
+ *
8935
+ * @param session - ORM session context
8936
+ * @param options - Cursor pagination options (`first`/`after` or `last`/`before`)
8937
+ * @returns Promise of cursor-paginated result with items and pageInfo
8938
+ * @example
8939
+ * const page1 = await selectFrom(users)
8940
+ * .orderBy(users.columns.createdAt, 'DESC')
8941
+ * .orderBy(users.columns.id, 'DESC')
8942
+ * .executeCursor(session, { first: 20 });
8943
+ *
8944
+ * // Next page
8945
+ * const page2 = await selectFrom(users)
8946
+ * .orderBy(users.columns.createdAt, 'DESC')
8947
+ * .orderBy(users.columns.id, 'DESC')
8948
+ * .executeCursor(session, { first: 20, after: page1.pageInfo.endCursor });
8949
+ *
8950
+ * // Previous page from a known cursor
8951
+ * const prevPage = await selectFrom(users)
8952
+ * .orderBy(users.columns.createdAt, 'DESC')
8953
+ * .orderBy(users.columns.id, 'DESC')
8954
+ * .executeCursor(session, { last: 20, before: page2.pageInfo.startCursor });
8955
+ */
8956
+ async executeCursor(session, options) {
8957
+ const builder = this.ensureDefaultSelection();
8958
+ return executeCursorQuery(builder, session, options);
8959
+ }
8960
+ /**
8961
+ * Executes the query and returns an array of values for a single column.
8744
8962
  * This is a convenience method to avoid manual `.map(r => r.column)`.
8745
8963
  *
8746
8964
  * @param column - The column name to extract