@getstrata/core 2.2.2 → 2.2.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @getstrata/core changelog
2
2
 
3
+ ## 2.2.4
4
+
5
+ - perf(database): seek Postgres composite keyset bounds
6
+
7
+ ## 2.2.3
8
+
9
+ - feat(database): add scoped composite keyset pagination
10
+
3
11
  ## 2.2.2
4
12
 
5
13
  - orm improvments
@@ -1,3 +1,4 @@
1
+ import type { KeysetCursor, KeysetOptions } from "../pagination/index.ts";
1
2
  import { type CursorPaginatedResult, type PaginatedResult } from "../pagination/index.ts";
2
3
  import { type BelongsToManyRelation, type BelongsToRelation, type HasManyRelation, type HasManyThroughRelation, type MorphManyRelation, type MorphOneRelation, type MorphToRelation } from "./relationships.ts";
3
4
  import { RepositoryQuery } from "./repositoryQuery.ts";
@@ -37,6 +38,7 @@ declare class BaseRepository<TEntity extends object, PrimaryKey extends keyof TE
37
38
  cursorColumn?: PrimaryKey;
38
39
  direction?: "asc" | "desc";
39
40
  } & Omit<ExtendedQueryOptions<TEntity>, "limit" | "offset">): Promise<CursorPaginatedResult<TEntity, TEntity[PrimaryKey]>>;
41
+ keysetPaginate(options: KeysetOptions<TEntity> & Omit<ExtendedQueryOptions<TEntity>, "orderBy" | "limit" | "offset">): Promise<CursorPaginatedResult<TEntity, KeysetCursor>>;
40
42
  findById(id: TEntity[PrimaryKey]): Promise<TEntity | null>;
41
43
  findByIdOrThrow(id: TEntity[PrimaryKey], errorFactory?: ErrorFactory<TEntity[PrimaryKey]>): Promise<TEntity>;
42
44
  findByIds(ids: readonly TEntity[PrimaryKey][]): Promise<TEntity[]>;
@@ -0,0 +1,7 @@
1
+ import type { KeysetCursor, KeysetOptions } from "../pagination/index.ts";
2
+ import type { TableDefinition } from "./table.ts";
3
+ import type { WhereNode } from "./whereBuilder.ts";
4
+ declare const KEYSET_ALIAS_PREFIX = "__strata_keyset_";
5
+ declare function validateKeyset<TEntity extends object, PK extends keyof TEntity & string>(table: TableDefinition<TEntity, PK>, options: KeysetOptions<TEntity>): KeysetCursor["order"];
6
+ declare function keysetBoundary<TEntity extends object>(order: KeysetCursor["order"], cursor?: KeysetCursor): WhereNode<TEntity>[];
7
+ export { KEYSET_ALIAS_PREFIX, keysetBoundary, validateKeyset };
@@ -1,3 +1,4 @@
1
+ import type { CursorPaginatedResult, KeysetCursor, KeysetOptions } from "../pagination/index.ts";
1
2
  import BaseRepository from "./baseRepository.ts";
2
3
  import type { AnyRelationQuery, RelatedModelClass } from "./relationQuery.ts";
3
4
  import { BelongsToManyRelationQuery, BelongsToRelationQuery, HasManyRelationQuery, HasManyThroughRelationQuery, HasOneRelationQuery, MorphManyRelationQuery, MorphOneRelationQuery, MorphToRelationQuery } from "./relationQuery.ts";
@@ -55,7 +56,9 @@ declare function applyCasts(values: LoadedAttributes, casts: ModelCasts, directi
55
56
  /** Read-only plain records; partial selection never acquires model mutation methods. */
56
57
  declare class ModelProjection<TEntity extends object, K extends keyof TEntity & string> {
57
58
  private readonly load;
58
- constructor(load: (limit?: number) => Promise<Array<Pick<TEntity, K>>>);
59
+ private readonly page;
60
+ constructor(load: (limit?: number) => Promise<Array<Pick<TEntity, K>>>, page: (options: KeysetOptions<TEntity>) => Promise<CursorPaginatedResult<Pick<TEntity, K>, KeysetCursor>>);
61
+ keysetPaginate(options: KeysetOptions<TEntity>): Promise<CursorPaginatedResult<Pick<TEntity, K>, KeysetCursor>>;
59
62
  get(): Promise<Array<Pick<TEntity, K>>>;
60
63
  first(): Promise<Pick<TEntity, K> | null>;
61
64
  }
@@ -106,6 +109,10 @@ declare class ModelQuery<TModel extends object = AnyModel, TCounts extends strin
106
109
  withTrashed(): this;
107
110
  onlyTrashed(): this;
108
111
  get(): Promise<ModelQueryResult<TModel, TCounts>[]>;
112
+ keysetPaginate(options: KeysetOptions<ModelAttributes<TModel>>): Promise<{
113
+ meta: import("../pagination/index.ts").CursorPaginationMeta<KeysetCursor>;
114
+ data: ModelQueryResult<TModel, TCounts>[];
115
+ }>;
109
116
  private hydrateRows;
110
117
  first(): Promise<ModelQueryResult<TModel, TCounts> | null>;
111
118
  count(): Promise<number>;
@@ -186,6 +193,12 @@ declare class Model<TEntity extends object, PrimaryKey extends keyof TEntity & s
186
193
  static chunk<TModel extends object>(this: {
187
194
  prototype: TModel;
188
195
  }, count: number, callback: (models: TModel[]) => Promise<boolean | void>): Promise<void>;
196
+ static keysetPaginate<TModel extends object>(this: {
197
+ prototype: TModel;
198
+ }, options: KeysetOptions<ModelAttributes<TModel>>): Promise<{
199
+ meta: import("../pagination/index.ts").CursorPaginationMeta<KeysetCursor>;
200
+ data: TModel[];
201
+ }>;
189
202
  static cursorPaginate<TModel extends object>(this: {
190
203
  prototype: TModel;
191
204
  }, options: {
@@ -1,4 +1,4 @@
1
- import type { PaginatedResult } from "../pagination/index.ts";
1
+ import type { KeysetOptions, PaginatedResult } from "../pagination/index.ts";
2
2
  import type BaseRepository from "./baseRepository.ts";
3
3
  import type { BelongsToManyRelation, BelongsToRelation, HasManyRelation, HasManyThroughRelation, MorphManyRelation, MorphOneRelation, MorphToRelation } from "./relationships.ts";
4
4
  import type { QueryLockOptions, QueryOptions, QueryWhere, UpdateValues } from "./types.ts";
@@ -66,6 +66,14 @@ declare class RepositoryQuery<TEntity extends object, PrimaryKey extends keyof T
66
66
  meta: import("../pagination/index.ts").CursorPaginationMeta<TEntity[PrimaryKey]>;
67
67
  data: (TEntity & LoadedRow)[];
68
68
  }>;
69
+ keysetPaginate(options: KeysetOptions<TEntity>): Promise<{
70
+ meta: import("../pagination/index.ts").CursorPaginationMeta<import("../pagination/index.ts").KeysetCursor>;
71
+ data: (TEntity & LoadedRow)[];
72
+ }>;
73
+ projectKeyset<K extends keyof TEntity & string>(columns: readonly K[], options: KeysetOptions<TEntity>): Promise<{
74
+ meta: import("../pagination/index.ts").CursorPaginationMeta<import("../pagination/index.ts").KeysetCursor>;
75
+ data: Array<Pick<TEntity, K>>;
76
+ }>;
69
77
  /** Compiled query filters for batched relationship loaders. */
70
78
  getOptions(): QueryOptions<TEntity>;
71
79
  private buildOptions;
@@ -58,6 +58,11 @@ type QueryJoin = {
58
58
  on: QueryJoinOn[];
59
59
  };
60
60
  type QuerySelectItem = {
61
+ kind: "textColumn";
62
+ table: string;
63
+ column: string;
64
+ as: string;
65
+ } | {
61
66
  kind: "column";
62
67
  table: string;
63
68
  column: string;
@@ -13,6 +13,13 @@ type WhereNode<TEntity extends object> = {
13
13
  } | {
14
14
  kind: "and" | "or";
15
15
  exists: ExistsClause;
16
+ } | {
17
+ kind: "and" | "or";
18
+ compareRow: {
19
+ columns: readonly string[];
20
+ operator: "lt" | "gt";
21
+ values: readonly unknown[];
22
+ };
16
23
  };
17
24
  declare class WhereBuilder<TEntity extends object, TWhere extends object = QueryWhere<TEntity>> {
18
25
  readonly nodes: WhereNode<TEntity>[];
@@ -25,3 +25,24 @@ declare function buildPaginationMeta(input: {
25
25
  }): PaginationMeta;
26
26
  export type { CursorPaginatedResult, CursorPaginationMeta, PaginatedResult, PaginationMeta };
27
27
  export { buildPaginationMeta };
28
+ /** Ordering columns must be non-null scalar fields in the underlying schema. */
29
+ type KeysetColumn<TEntity> = {
30
+ [K in keyof TEntity & string]: TEntity[K] extends string | number | bigint | Date ? K : never;
31
+ }[keyof TEntity & string];
32
+ interface KeysetCursor {
33
+ version: 1;
34
+ order: Array<{
35
+ column: string;
36
+ direction: "asc" | "desc";
37
+ }>;
38
+ values: string[];
39
+ }
40
+ interface KeysetOptions<TEntity> {
41
+ perPage: number;
42
+ orderBy: readonly {
43
+ column: KeysetColumn<TEntity>;
44
+ direction: "asc" | "desc";
45
+ }[];
46
+ cursor?: KeysetCursor;
47
+ }
48
+ export type { KeysetColumn, KeysetCursor, KeysetOptions };
@@ -149,6 +149,12 @@ function remapExistsSql(sql, existsParams, params) {
149
149
  return sql.replace(/\$(\d+)/g, (_match, index) => `$${offset + Number(index)}`);
150
150
  }
151
151
  function buildWhereNodeClause(tableName, node, params) {
152
+ if ("compareRow" in node) {
153
+ const { columns, operator, values } = node.compareRow;
154
+ if (!Array.isArray(columns) || !columns.length || !Array.isArray(values) || columns.length !== values.length || !["lt", "gt"].includes(operator) || Array.from(columns).some((column) => typeof column !== "string" || !column.length))
155
+ throw new TypeError("Invalid row comparison columns, values or operator.");
156
+ return `(${columns.map((column) => qualifyColumn(tableName, column)).join(", ")}) ${operator === "lt" ? "<" : ">"} (${values.map((value) => pushParam(params, value)).join(", ")})`;
157
+ }
152
158
  if ("exists" in node) {
153
159
  const body = remapExistsSql(node.exists.sql, node.exists.params, params);
154
160
  return `${node.exists.not ? "NOT " : ""}EXISTS (${body})`;
@@ -305,6 +311,12 @@ function buildSelectList(table, select, params = []) {
305
311
  const column = qualifyColumn(item.table, item.column);
306
312
  return item.as ? `${column} AS ${quoteIdentifier(item.as)}` : column;
307
313
  }
314
+ if (item.kind === "textColumn") {
315
+ const dialect = currentSqlDialect();
316
+ const column = qualifyColumn(item.table, item.column);
317
+ const text = dialect.driver === "sqlite" ? `CASE WHEN typeof(${column}) = 'real' THEN printf('%!.26g', ${column}) ELSE ${dialect.castToText(column)} END` : dialect.castToText(column);
318
+ return `${text} AS ${quoteIdentifier(item.as)}`;
319
+ }
308
320
  if (item.kind === "literalText") {
309
321
  return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
310
322
  }
@@ -173,6 +173,12 @@ function remapExistsSql(sql, existsParams, params) {
173
173
  return sql.replace(/\$(\d+)/g, (_match, index) => `$${offset + Number(index)}`);
174
174
  }
175
175
  function buildWhereNodeClause(tableName, node, params) {
176
+ if ("compareRow" in node) {
177
+ const { columns, operator, values } = node.compareRow;
178
+ if (!Array.isArray(columns) || !columns.length || !Array.isArray(values) || columns.length !== values.length || !["lt", "gt"].includes(operator) || Array.from(columns).some((column) => typeof column !== "string" || !column.length))
179
+ throw new TypeError("Invalid row comparison columns, values or operator.");
180
+ return `(${columns.map((column) => qualifyColumn(tableName, column)).join(", ")}) ${operator === "lt" ? "<" : ">"} (${values.map((value) => pushParam(params, value)).join(", ")})`;
181
+ }
176
182
  if ("exists" in node) {
177
183
  const body = remapExistsSql(node.exists.sql, node.exists.params, params);
178
184
  return `${node.exists.not ? "NOT " : ""}EXISTS (${body})`;
@@ -329,6 +335,12 @@ function buildSelectList(table, select, params = []) {
329
335
  const column = qualifyColumn(item.table, item.column);
330
336
  return item.as ? `${column} AS ${quoteIdentifier(item.as)}` : column;
331
337
  }
338
+ if (item.kind === "textColumn") {
339
+ const dialect = currentSqlDialect();
340
+ const column = qualifyColumn(item.table, item.column);
341
+ const text = dialect.driver === "sqlite" ? `CASE WHEN typeof(${column}) = 'real' THEN printf('%!.26g', ${column}) ELSE ${dialect.castToText(column)} END` : dialect.castToText(column);
342
+ return `${text} AS ${quoteIdentifier(item.as)}`;
343
+ }
332
344
  if (item.kind === "literalText") {
333
345
  return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
334
346
  }
@@ -1157,6 +1169,20 @@ class RepositoryQuery {
1157
1169
  const page = await this.repository.cursorPaginate({ ...this.buildOptions(), ...options });
1158
1170
  return { ...page, data: await this.attach(page.data) };
1159
1171
  }
1172
+ async keysetPaginate(options) {
1173
+ const page = await this.repository.keysetPaginate({ ...this.buildOptions(), ...options });
1174
+ return { ...page, data: await this.attach(page.data) };
1175
+ }
1176
+ async projectKeyset(columns, options) {
1177
+ if (!columns.length || columns.some((column) => !this.repository.getTable().columns.includes(column)))
1178
+ throw new TypeError("Keyset projections require declared columns.");
1179
+ const page = await this.repository.keysetPaginate({
1180
+ ...this.buildOptions(),
1181
+ ...options,
1182
+ select: uniqueColumnSelect(this.repository.getTable().name, columns)
1183
+ });
1184
+ return { ...page, data: page.data };
1185
+ }
1160
1186
  getOptions() {
1161
1187
  return this.buildOptions();
1162
1188
  }
@@ -126,7 +126,7 @@ export type { MetricLabels } from "../core/metrics/prometheus.ts";
126
126
  export { PrometheusRegistry, prometheusRegistry } from "../core/metrics/prometheus.ts";
127
127
  export type { DatabaseNotificationPayload, DatabaseNotificationStore, MailNotificationMessage, Notifiable, NotificationChannelName, } from "../core/notifications/index.ts";
128
128
  export { createNotificationDispatcher, Notification, NotificationDispatcher, } from "../core/notifications/index.ts";
129
- export type { CursorPaginatedResult, PaginatedResult, PaginationMeta, } from "../core/pagination/index.ts";
129
+ export type { CursorPaginatedResult, KeysetColumn, KeysetCursor, KeysetOptions, PaginatedResult, PaginationMeta, } from "../core/pagination/index.ts";
130
130
  export type { Queue, QueuePriority } from "../core/queue/index.ts";
131
131
  export { AsyncQueue, createQueue, Job, SyncQueue } from "../core/queue/index.ts";
132
132
  export { JobRegistry } from "../core/queue/jobRegistry.ts";
package/dist/index.js CHANGED
@@ -4131,6 +4131,58 @@ function buildPaginationMeta(input) {
4131
4131
  init_connectionContext();
4132
4132
  init_dialect();
4133
4133
 
4134
+ // ../../src/core/database/keyset.ts
4135
+ init_dialect();
4136
+ var KEYSET_ALIAS_PREFIX = "__strata_keyset_";
4137
+ function validateKeyset(table, options) {
4138
+ if (!Number.isInteger(options.perPage) || options.perPage < 1 || options.perPage > 1000)
4139
+ throw new RangeError("Keyset page size must be between 1 and 1000.");
4140
+ if (!Array.isArray(options.orderBy) || options.orderBy.length < 1 || options.orderBy.length > 8)
4141
+ throw new TypeError("Keyset ordering requires between 1 and 8 columns.");
4142
+ if (table.columns.some((column) => column.toLowerCase().startsWith(KEYSET_ALIAS_PREFIX)))
4143
+ throw new TypeError("Table columns conflict with reserved keyset aliases.");
4144
+ const seen = new Set;
4145
+ const order = Array.from(options.orderBy).map(({ column, direction }) => {
4146
+ if (!table.columns.includes(column) || seen.has(column) || !["asc", "desc"].includes(direction))
4147
+ throw new TypeError("Invalid or duplicate keyset ordering column/direction.");
4148
+ seen.add(column);
4149
+ return { column, direction };
4150
+ });
4151
+ if (order.at(-1)?.column !== table.primaryKey)
4152
+ throw new TypeError("Keyset ordering must end with the table primary key.");
4153
+ if (options.cursor !== undefined) {
4154
+ const cursor = options.cursor;
4155
+ if (cursor?.version !== 1 || !Array.isArray(cursor.order) || cursor.order.length !== order.length || !Array.isArray(cursor.values) || cursor.values.length !== order.length || Array.from(cursor.order).some((item, i) => !item || item.column !== order[i]?.column || item.direction !== order[i]?.direction) || Array.from(cursor.values).some((value) => typeof value !== "string" || value.length > 4096))
4156
+ throw new TypeError("Invalid keyset cursor or ordering mismatch.");
4157
+ }
4158
+ return order;
4159
+ }
4160
+ function keysetBoundary(order, cursor) {
4161
+ if (!cursor)
4162
+ return [];
4163
+ if (currentSqlDialect().driver === "pgsql" && order.length > 1 && order.every((item) => item.direction === order[0]?.direction)) {
4164
+ return [
4165
+ {
4166
+ kind: "and",
4167
+ compareRow: {
4168
+ columns: order.map((item) => item.column),
4169
+ operator: order[0]?.direction === "asc" ? "gt" : "lt",
4170
+ values: [...cursor.values]
4171
+ }
4172
+ }
4173
+ ];
4174
+ }
4175
+ const branches = order.map((item, i) => {
4176
+ const where = {};
4177
+ order.slice(0, i).forEach((prefix, j) => {
4178
+ where[prefix.column] = cursor.values[j];
4179
+ });
4180
+ where[item.column] = { [item.direction === "asc" ? "gt" : "lt"]: cursor.values[i] };
4181
+ return { kind: i === 0 ? "and" : "or", where };
4182
+ });
4183
+ return [{ kind: "and", group: branches }];
4184
+ }
4185
+
4134
4186
  // ../../src/core/database/query.ts
4135
4187
  init_dialect();
4136
4188
  function quoteIdentifier(identifier) {
@@ -4281,6 +4333,12 @@ function remapExistsSql(sql, existsParams, params) {
4281
4333
  return sql.replace(/\$(\d+)/g, (_match, index) => `$${offset + Number(index)}`);
4282
4334
  }
4283
4335
  function buildWhereNodeClause(tableName, node, params) {
4336
+ if ("compareRow" in node) {
4337
+ const { columns, operator, values } = node.compareRow;
4338
+ if (!Array.isArray(columns) || !columns.length || !Array.isArray(values) || columns.length !== values.length || !["lt", "gt"].includes(operator) || Array.from(columns).some((column) => typeof column !== "string" || !column.length))
4339
+ throw new TypeError("Invalid row comparison columns, values or operator.");
4340
+ return `(${columns.map((column) => qualifyColumn(tableName, column)).join(", ")}) ${operator === "lt" ? "<" : ">"} (${values.map((value) => pushParam(params, value)).join(", ")})`;
4341
+ }
4284
4342
  if ("exists" in node) {
4285
4343
  const body = remapExistsSql(node.exists.sql, node.exists.params, params);
4286
4344
  return `${node.exists.not ? "NOT " : ""}EXISTS (${body})`;
@@ -4437,6 +4495,12 @@ function buildSelectList(table, select, params = []) {
4437
4495
  const column = qualifyColumn(item.table, item.column);
4438
4496
  return item.as ? `${column} AS ${quoteIdentifier(item.as)}` : column;
4439
4497
  }
4498
+ if (item.kind === "textColumn") {
4499
+ const dialect = currentSqlDialect();
4500
+ const column = qualifyColumn(item.table, item.column);
4501
+ const text = dialect.driver === "sqlite" ? `CASE WHEN typeof(${column}) = 'real' THEN printf('%!.26g', ${column}) ELSE ${dialect.castToText(column)} END` : dialect.castToText(column);
4502
+ return `${text} AS ${quoteIdentifier(item.as)}`;
4503
+ }
4440
4504
  if (item.kind === "literalText") {
4441
4505
  return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
4442
4506
  }
@@ -5292,6 +5356,20 @@ class RepositoryQuery {
5292
5356
  const page = await this.repository.cursorPaginate({ ...this.buildOptions(), ...options });
5293
5357
  return { ...page, data: await this.attach(page.data) };
5294
5358
  }
5359
+ async keysetPaginate(options) {
5360
+ const page = await this.repository.keysetPaginate({ ...this.buildOptions(), ...options });
5361
+ return { ...page, data: await this.attach(page.data) };
5362
+ }
5363
+ async projectKeyset(columns, options) {
5364
+ if (!columns.length || columns.some((column) => !this.repository.getTable().columns.includes(column)))
5365
+ throw new TypeError("Keyset projections require declared columns.");
5366
+ const page = await this.repository.keysetPaginate({
5367
+ ...this.buildOptions(),
5368
+ ...options,
5369
+ select: uniqueColumnSelect(this.repository.getTable().name, columns)
5370
+ });
5371
+ return { ...page, data: page.data };
5372
+ }
5295
5373
  getOptions() {
5296
5374
  return this.buildOptions();
5297
5375
  }
@@ -5557,6 +5635,65 @@ class BaseRepository {
5557
5635
  }
5558
5636
  };
5559
5637
  }
5638
+ async keysetPaginate(options) {
5639
+ const order = validateKeyset(this.table, options);
5640
+ if (options.joins?.length || options.groupBy || options.having || "offset" in options || "limit" in options)
5641
+ throw new TypeError("Keyset pagination does not accept joined/grouped/offset/limited queries.");
5642
+ if (options.select?.some((item) => item.as?.toLowerCase().startsWith(KEYSET_ALIAS_PREFIX)))
5643
+ throw new TypeError("Projection conflicts with reserved keyset aliases.");
5644
+ const filters = [...options.whereNodes ?? []];
5645
+ if (options.where)
5646
+ filters.push({ kind: "and", where: options.where });
5647
+ const rows = await this.findAll({
5648
+ ...options,
5649
+ where: {},
5650
+ whereNodes: [
5651
+ { kind: "and", group: filters },
5652
+ ...keysetBoundary(order, options.cursor)
5653
+ ],
5654
+ orderBy: order,
5655
+ limit: options.perPage + 1,
5656
+ select: [
5657
+ ...options.select?.length ? options.select : this.table.columns.map((column) => ({
5658
+ kind: "column",
5659
+ table: this.table.name,
5660
+ column
5661
+ })),
5662
+ ...order.map((item, i) => ({
5663
+ kind: "textColumn",
5664
+ table: this.table.name,
5665
+ column: item.column,
5666
+ as: `${KEYSET_ALIAS_PREFIX}${i}`
5667
+ }))
5668
+ ]
5669
+ });
5670
+ const entries = rows.map((row) => {
5671
+ const record = { ...row };
5672
+ const values = order.map((_, i) => {
5673
+ const key = `${KEYSET_ALIAS_PREFIX}${i}`;
5674
+ const value = record[key];
5675
+ delete record[key];
5676
+ if (typeof value !== "string" || value.length > 4096)
5677
+ throw new TypeError("Keyset columns must contain non-null bounded scalar values.");
5678
+ return value;
5679
+ });
5680
+ return { record, values };
5681
+ });
5682
+ const hasMore = entries.length > options.perPage;
5683
+ const visible = entries.slice(0, options.perPage);
5684
+ const last = visible.at(-1);
5685
+ const nextCursor = hasMore && last ? { version: 1, order, values: last.values } : null;
5686
+ const data = visible.map(({ record }) => record);
5687
+ return {
5688
+ data,
5689
+ meta: {
5690
+ per_page: options.perPage,
5691
+ has_more: hasMore,
5692
+ next_cursor: nextCursor,
5693
+ prev_cursor: options.cursor ?? null
5694
+ }
5695
+ };
5696
+ }
5560
5697
  async findById(id) {
5561
5698
  return await this.firstOrNull({
5562
5699
  [this.table.primaryKey]: id
@@ -7244,8 +7381,13 @@ function applyTimestampsOnUpdate(columns, values, enabled) {
7244
7381
 
7245
7382
  class ModelProjection {
7246
7383
  load;
7247
- constructor(load) {
7384
+ page;
7385
+ constructor(load, page) {
7248
7386
  this.load = load;
7387
+ this.page = page;
7388
+ }
7389
+ keysetPaginate(options) {
7390
+ return this.page(options);
7249
7391
  }
7250
7392
  get() {
7251
7393
  return this.load();
@@ -7295,6 +7437,12 @@ class ModelQuery {
7295
7437
  return new ModelProjection(async (limit) => {
7296
7438
  const rows = await this.query.project(columns, limit);
7297
7439
  return rows.map((row) => applyCasts(row, modelStatics(this.modelClass).$casts, "hydrate"));
7440
+ }, async (options) => {
7441
+ const page = await this.query.projectKeyset(columns, options);
7442
+ return {
7443
+ ...page,
7444
+ data: page.data.map((row) => applyCasts(row, modelStatics(this.modelClass).$casts, "hydrate"))
7445
+ };
7298
7446
  });
7299
7447
  }
7300
7448
  lockForUpdate(options = {}) {
@@ -7412,6 +7560,10 @@ class ModelQuery {
7412
7560
  async get() {
7413
7561
  return await this.hydrateRows(await this.query.get());
7414
7562
  }
7563
+ async keysetPaginate(options) {
7564
+ const page = await this.query.keysetPaginate(options);
7565
+ return { ...page, data: await this.hydrateRows(page.data) };
7566
+ }
7415
7567
  async hydrateRows(rows) {
7416
7568
  const statics = modelStatics(this.modelClass);
7417
7569
  const models = [];
@@ -7695,6 +7847,9 @@ class Model {
7695
7847
  return await callback(rows.map((row) => statics.newFromRecord(row, true)));
7696
7848
  });
7697
7849
  }
7850
+ static keysetPaginate(options) {
7851
+ return Model.query.call(this).keysetPaginate(options);
7852
+ }
7698
7853
  static async cursorPaginate(options) {
7699
7854
  const statics = modelStatics(this);
7700
7855
  ensureBooted(this);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/core",
3
- "version": "2.2.2",
3
+ "version": "2.2.4",
4
4
  "description": "Strata Bun framework public API",
5
5
  "type": "module",
6
6
  "license": "MIT",