@getstrata/core 2.2.2 → 2.2.3

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,9 @@
1
1
  # @getstrata/core changelog
2
2
 
3
+ ## 2.2.3
4
+
5
+ - feat(database): add scoped composite keyset pagination
6
+
3
7
  ## 2.2.2
4
8
 
5
9
  - 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;
@@ -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 };
@@ -305,6 +305,12 @@ function buildSelectList(table, select, params = []) {
305
305
  const column = qualifyColumn(item.table, item.column);
306
306
  return item.as ? `${column} AS ${quoteIdentifier(item.as)}` : column;
307
307
  }
308
+ if (item.kind === "textColumn") {
309
+ const dialect = currentSqlDialect();
310
+ const column = qualifyColumn(item.table, item.column);
311
+ const text = dialect.driver === "sqlite" ? `CASE WHEN typeof(${column}) = 'real' THEN printf('%!.26g', ${column}) ELSE ${dialect.castToText(column)} END` : dialect.castToText(column);
312
+ return `${text} AS ${quoteIdentifier(item.as)}`;
313
+ }
308
314
  if (item.kind === "literalText") {
309
315
  return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
310
316
  }
@@ -329,6 +329,12 @@ function buildSelectList(table, select, params = []) {
329
329
  const column = qualifyColumn(item.table, item.column);
330
330
  return item.as ? `${column} AS ${quoteIdentifier(item.as)}` : column;
331
331
  }
332
+ if (item.kind === "textColumn") {
333
+ const dialect = currentSqlDialect();
334
+ const column = qualifyColumn(item.table, item.column);
335
+ const text = dialect.driver === "sqlite" ? `CASE WHEN typeof(${column}) = 'real' THEN printf('%!.26g', ${column}) ELSE ${dialect.castToText(column)} END` : dialect.castToText(column);
336
+ return `${text} AS ${quoteIdentifier(item.as)}`;
337
+ }
332
338
  if (item.kind === "literalText") {
333
339
  return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
334
340
  }
@@ -1157,6 +1163,20 @@ class RepositoryQuery {
1157
1163
  const page = await this.repository.cursorPaginate({ ...this.buildOptions(), ...options });
1158
1164
  return { ...page, data: await this.attach(page.data) };
1159
1165
  }
1166
+ async keysetPaginate(options) {
1167
+ const page = await this.repository.keysetPaginate({ ...this.buildOptions(), ...options });
1168
+ return { ...page, data: await this.attach(page.data) };
1169
+ }
1170
+ async projectKeyset(columns, options) {
1171
+ if (!columns.length || columns.some((column) => !this.repository.getTable().columns.includes(column)))
1172
+ throw new TypeError("Keyset projections require declared columns.");
1173
+ const page = await this.repository.keysetPaginate({
1174
+ ...this.buildOptions(),
1175
+ ...options,
1176
+ select: uniqueColumnSelect(this.repository.getTable().name, columns)
1177
+ });
1178
+ return { ...page, data: page.data };
1179
+ }
1160
1180
  getOptions() {
1161
1181
  return this.buildOptions();
1162
1182
  }
@@ -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,45 @@ function buildPaginationMeta(input) {
4131
4131
  init_connectionContext();
4132
4132
  init_dialect();
4133
4133
 
4134
+ // ../../src/core/database/keyset.ts
4135
+ var KEYSET_ALIAS_PREFIX = "__strata_keyset_";
4136
+ function validateKeyset(table, options) {
4137
+ if (!Number.isInteger(options.perPage) || options.perPage < 1 || options.perPage > 1000)
4138
+ throw new RangeError("Keyset page size must be between 1 and 1000.");
4139
+ if (!Array.isArray(options.orderBy) || options.orderBy.length < 1 || options.orderBy.length > 8)
4140
+ throw new TypeError("Keyset ordering requires between 1 and 8 columns.");
4141
+ if (table.columns.some((column) => column.toLowerCase().startsWith(KEYSET_ALIAS_PREFIX)))
4142
+ throw new TypeError("Table columns conflict with reserved keyset aliases.");
4143
+ const seen = new Set;
4144
+ const order = Array.from(options.orderBy).map(({ column, direction }) => {
4145
+ if (!table.columns.includes(column) || seen.has(column) || !["asc", "desc"].includes(direction))
4146
+ throw new TypeError("Invalid or duplicate keyset ordering column/direction.");
4147
+ seen.add(column);
4148
+ return { column, direction };
4149
+ });
4150
+ if (order.at(-1)?.column !== table.primaryKey)
4151
+ throw new TypeError("Keyset ordering must end with the table primary key.");
4152
+ if (options.cursor !== undefined) {
4153
+ const cursor = options.cursor;
4154
+ 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))
4155
+ throw new TypeError("Invalid keyset cursor or ordering mismatch.");
4156
+ }
4157
+ return order;
4158
+ }
4159
+ function keysetBoundary(order, cursor) {
4160
+ if (!cursor)
4161
+ return [];
4162
+ const branches = order.map((item, i) => {
4163
+ const where = {};
4164
+ order.slice(0, i).forEach((prefix, j) => {
4165
+ where[prefix.column] = cursor.values[j];
4166
+ });
4167
+ where[item.column] = { [item.direction === "asc" ? "gt" : "lt"]: cursor.values[i] };
4168
+ return { kind: i === 0 ? "and" : "or", where };
4169
+ });
4170
+ return [{ kind: "and", group: branches }];
4171
+ }
4172
+
4134
4173
  // ../../src/core/database/query.ts
4135
4174
  init_dialect();
4136
4175
  function quoteIdentifier(identifier) {
@@ -4437,6 +4476,12 @@ function buildSelectList(table, select, params = []) {
4437
4476
  const column = qualifyColumn(item.table, item.column);
4438
4477
  return item.as ? `${column} AS ${quoteIdentifier(item.as)}` : column;
4439
4478
  }
4479
+ if (item.kind === "textColumn") {
4480
+ const dialect = currentSqlDialect();
4481
+ const column = qualifyColumn(item.table, item.column);
4482
+ const text = dialect.driver === "sqlite" ? `CASE WHEN typeof(${column}) = 'real' THEN printf('%!.26g', ${column}) ELSE ${dialect.castToText(column)} END` : dialect.castToText(column);
4483
+ return `${text} AS ${quoteIdentifier(item.as)}`;
4484
+ }
4440
4485
  if (item.kind === "literalText") {
4441
4486
  return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
4442
4487
  }
@@ -5292,6 +5337,20 @@ class RepositoryQuery {
5292
5337
  const page = await this.repository.cursorPaginate({ ...this.buildOptions(), ...options });
5293
5338
  return { ...page, data: await this.attach(page.data) };
5294
5339
  }
5340
+ async keysetPaginate(options) {
5341
+ const page = await this.repository.keysetPaginate({ ...this.buildOptions(), ...options });
5342
+ return { ...page, data: await this.attach(page.data) };
5343
+ }
5344
+ async projectKeyset(columns, options) {
5345
+ if (!columns.length || columns.some((column) => !this.repository.getTable().columns.includes(column)))
5346
+ throw new TypeError("Keyset projections require declared columns.");
5347
+ const page = await this.repository.keysetPaginate({
5348
+ ...this.buildOptions(),
5349
+ ...options,
5350
+ select: uniqueColumnSelect(this.repository.getTable().name, columns)
5351
+ });
5352
+ return { ...page, data: page.data };
5353
+ }
5295
5354
  getOptions() {
5296
5355
  return this.buildOptions();
5297
5356
  }
@@ -5557,6 +5616,65 @@ class BaseRepository {
5557
5616
  }
5558
5617
  };
5559
5618
  }
5619
+ async keysetPaginate(options) {
5620
+ const order = validateKeyset(this.table, options);
5621
+ if (options.joins?.length || options.groupBy || options.having || "offset" in options || "limit" in options)
5622
+ throw new TypeError("Keyset pagination does not accept joined/grouped/offset/limited queries.");
5623
+ if (options.select?.some((item) => item.as?.toLowerCase().startsWith(KEYSET_ALIAS_PREFIX)))
5624
+ throw new TypeError("Projection conflicts with reserved keyset aliases.");
5625
+ const filters = [...options.whereNodes ?? []];
5626
+ if (options.where)
5627
+ filters.push({ kind: "and", where: options.where });
5628
+ const rows = await this.findAll({
5629
+ ...options,
5630
+ where: {},
5631
+ whereNodes: [
5632
+ { kind: "and", group: filters },
5633
+ ...keysetBoundary(order, options.cursor)
5634
+ ],
5635
+ orderBy: order,
5636
+ limit: options.perPage + 1,
5637
+ select: [
5638
+ ...options.select?.length ? options.select : this.table.columns.map((column) => ({
5639
+ kind: "column",
5640
+ table: this.table.name,
5641
+ column
5642
+ })),
5643
+ ...order.map((item, i) => ({
5644
+ kind: "textColumn",
5645
+ table: this.table.name,
5646
+ column: item.column,
5647
+ as: `${KEYSET_ALIAS_PREFIX}${i}`
5648
+ }))
5649
+ ]
5650
+ });
5651
+ const entries = rows.map((row) => {
5652
+ const record = { ...row };
5653
+ const values = order.map((_, i) => {
5654
+ const key = `${KEYSET_ALIAS_PREFIX}${i}`;
5655
+ const value = record[key];
5656
+ delete record[key];
5657
+ if (typeof value !== "string" || value.length > 4096)
5658
+ throw new TypeError("Keyset columns must contain non-null bounded scalar values.");
5659
+ return value;
5660
+ });
5661
+ return { record, values };
5662
+ });
5663
+ const hasMore = entries.length > options.perPage;
5664
+ const visible = entries.slice(0, options.perPage);
5665
+ const last = visible.at(-1);
5666
+ const nextCursor = hasMore && last ? { version: 1, order, values: last.values } : null;
5667
+ const data = visible.map(({ record }) => record);
5668
+ return {
5669
+ data,
5670
+ meta: {
5671
+ per_page: options.perPage,
5672
+ has_more: hasMore,
5673
+ next_cursor: nextCursor,
5674
+ prev_cursor: options.cursor ?? null
5675
+ }
5676
+ };
5677
+ }
5560
5678
  async findById(id) {
5561
5679
  return await this.firstOrNull({
5562
5680
  [this.table.primaryKey]: id
@@ -7244,8 +7362,13 @@ function applyTimestampsOnUpdate(columns, values, enabled) {
7244
7362
 
7245
7363
  class ModelProjection {
7246
7364
  load;
7247
- constructor(load) {
7365
+ page;
7366
+ constructor(load, page) {
7248
7367
  this.load = load;
7368
+ this.page = page;
7369
+ }
7370
+ keysetPaginate(options) {
7371
+ return this.page(options);
7249
7372
  }
7250
7373
  get() {
7251
7374
  return this.load();
@@ -7295,6 +7418,12 @@ class ModelQuery {
7295
7418
  return new ModelProjection(async (limit) => {
7296
7419
  const rows = await this.query.project(columns, limit);
7297
7420
  return rows.map((row) => applyCasts(row, modelStatics(this.modelClass).$casts, "hydrate"));
7421
+ }, async (options) => {
7422
+ const page = await this.query.projectKeyset(columns, options);
7423
+ return {
7424
+ ...page,
7425
+ data: page.data.map((row) => applyCasts(row, modelStatics(this.modelClass).$casts, "hydrate"))
7426
+ };
7298
7427
  });
7299
7428
  }
7300
7429
  lockForUpdate(options = {}) {
@@ -7412,6 +7541,10 @@ class ModelQuery {
7412
7541
  async get() {
7413
7542
  return await this.hydrateRows(await this.query.get());
7414
7543
  }
7544
+ async keysetPaginate(options) {
7545
+ const page = await this.query.keysetPaginate(options);
7546
+ return { ...page, data: await this.hydrateRows(page.data) };
7547
+ }
7415
7548
  async hydrateRows(rows) {
7416
7549
  const statics = modelStatics(this.modelClass);
7417
7550
  const models = [];
@@ -7695,6 +7828,9 @@ class Model {
7695
7828
  return await callback(rows.map((row) => statics.newFromRecord(row, true)));
7696
7829
  });
7697
7830
  }
7831
+ static keysetPaginate(options) {
7832
+ return Model.query.call(this).keysetPaginate(options);
7833
+ }
7698
7834
  static async cursorPaginate(options) {
7699
7835
  const statics = modelStatics(this);
7700
7836
  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.3",
4
4
  "description": "Strata Bun framework public API",
5
5
  "type": "module",
6
6
  "license": "MIT",