@getstrata/core 2.2.1 → 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,13 @@
1
1
  # @getstrata/core changelog
2
2
 
3
+ ## 2.2.3
4
+
5
+ - feat(database): add scoped composite keyset pagination
6
+
7
+ ## 2.2.2
8
+
9
+ - orm improvments
10
+
3
11
  ## 2.2.1
4
12
 
5
13
  F02 — complete queue durability. Queues have leased reservations, recovery and fenced acknowledgement, but still use Redis lists. Persisted retry schedules, execution deadlines, cancellation and the planned Streams migration remain unfinished. Retries currently wait inside the running worker.
@@ -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[]>;
@@ -45,6 +47,11 @@ declare class BaseRepository<TEntity extends object, PrimaryKey extends keyof TE
45
47
  incrementById(id: TEntity[PrimaryKey], column: keyof TEntity & string, amount?: number, extra?: UpdateValues<TEntity, PrimaryKey>): Promise<TEntity | null>;
46
48
  decrementById(id: TEntity[PrimaryKey], column: keyof TEntity & string, amount?: number, extra?: UpdateValues<TEntity, PrimaryKey>): Promise<TEntity | null>;
47
49
  create(values: MutationValues<TEntity>): Promise<TEntity>;
50
+ /** Set-based writes intentionally bypass per-row model observers and write casts. */
51
+ updateWhere(changes: UpdateValues<TEntity, PrimaryKey>, options: QueryOptions<TEntity>): Promise<number>;
52
+ deleteWhere(options: QueryOptions<TEntity>): Promise<number>;
53
+ updateWhereReturning<K extends keyof TEntity & string>(changes: UpdateValues<TEntity, PrimaryKey>, columns: readonly K[], options: QueryOptions<TEntity>): Promise<Pick<TEntity, K>[]>;
54
+ private executeConditionalWrite;
48
55
  updateById(id: TEntity[PrimaryKey], changes: UpdateValues<TEntity, PrimaryKey>): Promise<TEntity | null>;
49
56
  updateByIdOrThrow(id: TEntity[PrimaryKey], changes: UpdateValues<TEntity, PrimaryKey>, errorFactory?: ErrorFactory<TEntity[PrimaryKey]>): Promise<TEntity>;
50
57
  deleteById(id: TEntity[PrimaryKey]): Promise<boolean>;
@@ -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,10 +1,11 @@
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";
4
5
  import type { BelongsToManyRelation, BelongsToRelation, HasManyRelation, HasOneRelation } from "./relationships.ts";
5
6
  import type { RepositoryQuery } from "./repositoryQuery.ts";
6
7
  import type { TableDefinition } from "./table.ts";
7
- import type { ModelWhere, ModelWriteValues, QueryOptions, QueryWhere } from "./types.ts";
8
+ import type { ModelWhere, ModelWriteValues, QueryLockOptions, QueryOptions, QueryWhere } from "./types.ts";
8
9
  type CastType = "date" | "datetime" | "json" | "bool" | "boolean" | "integer" | "int" | "hashed";
9
10
  type LoadedAttributes = Record<string, unknown>;
10
11
  type ModelCasts = Partial<Record<string, CastType>>;
@@ -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
  }
@@ -70,6 +73,8 @@ declare class ModelQuery<TModel extends object = AnyModel, TCounts extends strin
70
73
  /** Qualified/dynamic filters retain existing SQL semantics through an explicit escape hatch. */
71
74
  whereDynamic(input: QueryWhere<object>): this;
72
75
  select<K extends keyof ModelAttributes<TModel> & string>(...columns: readonly K[]): ModelProjection<ModelAttributes<TModel>, K>;
76
+ lockForUpdate(options?: QueryLockOptions): this;
77
+ sharedLock(options?: QueryLockOptions): this;
73
78
  orderBy(orderBy: QueryOptions<ModelAttributes<TModel>>["orderBy"]): this;
74
79
  limit(limit: number): this;
75
80
  offset(offset: number): this;
@@ -104,6 +109,10 @@ declare class ModelQuery<TModel extends object = AnyModel, TCounts extends strin
104
109
  withTrashed(): this;
105
110
  onlyTrashed(): this;
106
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
+ }>;
107
116
  private hydrateRows;
108
117
  first(): Promise<ModelQueryResult<TModel, TCounts> | null>;
109
118
  count(): Promise<number>;
@@ -184,6 +193,12 @@ declare class Model<TEntity extends object, PrimaryKey extends keyof TEntity & s
184
193
  static chunk<TModel extends object>(this: {
185
194
  prototype: TModel;
186
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
+ }>;
187
202
  static cursorPaginate<TModel extends object>(this: {
188
203
  prototype: TModel;
189
204
  }, options: {
@@ -68,4 +68,9 @@ declare function buildDeleteByIdQuery<TEntity extends object, PrimaryKey extends
68
68
  text: string;
69
69
  params: unknown[];
70
70
  };
71
- export { assertSafeProjectionExpression, buildAdvancedWhereClause, buildCountQuery, buildDeleteByIdQuery, buildGroupedCountQuery, buildIncrementQuery, buildInsertQuery, buildJoinClause, buildOrderByClause, buildProjectionQuery, buildQueryWhereClause, buildRestoreByIdQuery, buildSelectQuery, buildSoftDeleteByIdQuery, buildUpdateQuery, buildUpsertQuery, buildWhereClause, parseQualifiedColumn, qualifyColumn, quoteIdentifier, resolveQualifiedColumn, resolveSoftDeleteColumn, };
71
+ /** Single-statement writes: predicates and protected scopes stay in the mutation. */
72
+ declare function buildConditionalWriteQuery<TEntity extends object, PrimaryKey extends keyof TEntity & string>(table: TableDefinition<TEntity, PrimaryKey>, operation: "update" | "delete", changes: UpdateValues<TEntity, PrimaryKey>, options: QueryOptions<TEntity>, returning?: readonly (keyof TEntity & string)[]): {
73
+ text: string;
74
+ params: unknown[];
75
+ };
76
+ export { assertSafeProjectionExpression, buildAdvancedWhereClause, buildConditionalWriteQuery, buildCountQuery, buildDeleteByIdQuery, buildGroupedCountQuery, buildIncrementQuery, buildInsertQuery, buildJoinClause, buildOrderByClause, buildProjectionQuery, buildQueryWhereClause, buildRestoreByIdQuery, buildSelectQuery, buildSoftDeleteByIdQuery, buildUpdateQuery, buildUpsertQuery, buildWhereClause, parseQualifiedColumn, qualifyColumn, quoteIdentifier, resolveQualifiedColumn, resolveSoftDeleteColumn, };
@@ -1,7 +1,7 @@
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
- import type { QueryOptions, QueryWhere } from "./types.ts";
4
+ import type { QueryLockOptions, QueryOptions, QueryWhere, UpdateValues } from "./types.ts";
5
5
  import { WhereBuilder } from "./whereBuilder.ts";
6
6
  type LoadedRow = Record<string, unknown>;
7
7
  declare class RepositoryQuery<TEntity extends object, PrimaryKey extends keyof TEntity & string> {
@@ -16,6 +16,8 @@ declare class RepositoryQuery<TEntity extends object, PrimaryKey extends keyof T
16
16
  /** Preserve completed scope predicates separately from subsequent caller OR conditions. */
17
17
  protectWhere(): this;
18
18
  orWhere(input: QueryWhere<TEntity> | ((builder: WhereBuilder<TEntity>) => void)): this;
19
+ lockForUpdate(options?: QueryLockOptions): this;
20
+ sharedLock(options?: QueryLockOptions): this;
19
21
  orderBy(orderBy: QueryOptions<TEntity>["orderBy"]): this;
20
22
  limit(limit: number): this;
21
23
  whereNull(column: keyof TEntity & string): this;
@@ -39,6 +41,10 @@ declare class RepositoryQuery<TEntity extends object, PrimaryKey extends keyof T
39
41
  withHasManyThrough<TFar extends object, LocalKey extends keyof TEntity & string, SecondKey extends keyof TFar & string, Alias extends string>(as: Alias, relation: HasManyThroughRelation<TEntity, TFar, LocalKey, string, string, SecondKey>, farRepository: BaseRepository<TFar, keyof TFar & string>, options?: Omit<QueryOptions<TFar>, "where">): this;
40
42
  withTrashed(): this;
41
43
  onlyTrashed(): this;
44
+ update(changes: UpdateValues<TEntity, PrimaryKey>): Promise<number>;
45
+ updateReturning<K extends keyof TEntity & string>(changes: UpdateValues<TEntity, PrimaryKey>, ...columns: K[]): Promise<Pick<TEntity, K>[]>;
46
+ delete(): Promise<number>;
47
+ private assertNoMutationEagerLoads;
42
48
  get(): Promise<Array<TEntity & LoadedRow>>;
43
49
  first(): Promise<(TEntity & LoadedRow) | null>;
44
50
  count(): Promise<number>;
@@ -60,6 +66,14 @@ declare class RepositoryQuery<TEntity extends object, PrimaryKey extends keyof T
60
66
  meta: import("../pagination/index.ts").CursorPaginationMeta<TEntity[PrimaryKey]>;
61
67
  data: (TEntity & LoadedRow)[];
62
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
+ }>;
63
77
  /** Compiled query filters for batched relationship loaders. */
64
78
  getOptions(): QueryOptions<TEntity>;
65
79
  private buildOptions;
@@ -30,6 +30,8 @@ declare class Blueprint {
30
30
  readonly droppedIndexes: string[];
31
31
  constructor(table: string, action: BlueprintAction);
32
32
  id(name?: string): ColumnDefinition;
33
+ /** Generated 64-bit primary key; does not change existing id() migrations. */
34
+ bigId(name?: string): ColumnDefinition;
33
35
  string(name: string, length?: number): ColumnDefinition;
34
36
  text(name: string): ColumnDefinition;
35
37
  boolean(name: string): ColumnDefinition;
@@ -1,4 +1,4 @@
1
- type ColumnKind = "id" | "string" | "text" | "boolean" | "integer" | "bigInteger" | "timestamp" | "json" | "jsonb" | "foreignId";
1
+ type ColumnKind = "id" | "bigId" | "string" | "text" | "boolean" | "integer" | "bigInteger" | "timestamp" | "json" | "jsonb" | "foreignId";
2
2
  interface ForeignKeyOptions {
3
3
  referencesTable: string;
4
4
  referencesColumn: string;
@@ -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;
@@ -78,7 +83,16 @@ type QuerySelectItem = {
78
83
  query: string;
79
84
  as: string;
80
85
  };
86
+ type QueryLockOptions = {
87
+ wait?: "wait" | "nowait" | "skipLocked";
88
+ /** PostgreSQL table names in this query; aliases are not inferred. */
89
+ of?: readonly string[];
90
+ };
91
+ type QueryLock = QueryLockOptions & {
92
+ mode: "update" | "share";
93
+ };
81
94
  interface QueryOptions<TEntity extends object> {
95
+ lock?: QueryLock;
82
96
  whereNodes?: WhereNode<TEntity>[];
83
97
  where?: QueryWhere<TEntity>;
84
98
  orderBy?: QueryOrder<TEntity> | QueryOrder<TEntity>[] | QueryOrderShorthand<TEntity>;
@@ -93,4 +107,4 @@ interface QueryOptions<TEntity extends object> {
93
107
  }
94
108
  type MutationValues<TEntity> = Partial<TEntity>;
95
109
  type UpdateValues<TEntity, PrimaryKey extends keyof TEntity & string = keyof TEntity & string> = Partial<Omit<TEntity, PrimaryKey>>;
96
- export type { DatabaseComparable, DatabaseScalar, ModelWhere, ModelWriteValues, MutationValues, QueryFilterValue, QueryJoin, QueryJoinOn, QueryOperator, QueryOptions, QueryOrder, QuerySelectItem, QueryWhere, UpdateValues, };
110
+ export type { DatabaseComparable, DatabaseScalar, ModelWhere, ModelWriteValues, MutationValues, QueryFilterValue, QueryJoin, QueryJoinOn, QueryLock, QueryLockOptions, QueryOperator, QueryOptions, QueryOrder, QuerySelectItem, QueryWhere, UpdateValues, };
@@ -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
  }
@@ -331,6 +337,31 @@ function getDefinedColumnEntries(table, values, options = {}) {
331
337
  return [[column, value]];
332
338
  });
333
339
  }
340
+ function buildSelectLock(table, options) {
341
+ const lock = options.lock;
342
+ if (!lock)
343
+ return "";
344
+ const driver = currentSqlDialect().driver;
345
+ if (driver === "sqlite")
346
+ throw new Error("Row locks are not supported on SQLite; use an explicit write transaction.");
347
+ if (lock.mode !== "update" && lock.mode !== "share")
348
+ throw new TypeError("Invalid row lock mode.");
349
+ if (lock.wait !== undefined && !["wait", "nowait", "skipLocked"].includes(lock.wait))
350
+ throw new TypeError("Invalid row lock wait policy.");
351
+ if (options.groupBy || options.having)
352
+ throw new Error("Row locks cannot be combined with grouped/aggregate queries.");
353
+ let target = "";
354
+ if (lock.of !== undefined) {
355
+ if (driver !== "pgsql")
356
+ throw new Error("Row lock targets are supported only on PostgreSQL.");
357
+ const tables = [table.name, ...(options.joins ?? []).map((join) => join.table)];
358
+ if (!lock.of.length || lock.of.some((name) => !tables.includes(name)))
359
+ throw new TypeError("Row lock targets must name tables in this query.");
360
+ target = ` OF ${lock.of.map(quoteIdentifier).join(", ")}`;
361
+ }
362
+ const wait = lock.wait === "nowait" ? " NOWAIT" : lock.wait === "skipLocked" ? " SKIP LOCKED" : "";
363
+ return ` FOR ${lock.mode === "update" ? "UPDATE" : "SHARE"}${target}${wait}`;
364
+ }
334
365
  function buildSelectQuery(table, options = {}, whereNodes = []) {
335
366
  const params = [];
336
367
  const columns = buildSelectList(table, options.select, params);
@@ -341,8 +372,9 @@ function buildSelectQuery(table, options = {}, whereNodes = []) {
341
372
  const orderBy = buildOrderByClause(table.name, options.orderBy ?? table.defaultOrderBy);
342
373
  const limit = buildLimitClause(options.limit);
343
374
  const offset = buildOffsetClause(options.offset);
375
+ const lock = buildSelectLock(table, options);
344
376
  return {
345
- text: `SELECT ${columns} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${havingClause}${orderBy}${limit}${offset}`,
377
+ text: `SELECT ${columns} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${havingClause}${orderBy}${limit}${offset}${lock}`,
346
378
  params
347
379
  };
348
380
  }
@@ -511,9 +543,52 @@ function buildDeleteByIdQuery(table, id) {
511
543
  params
512
544
  };
513
545
  }
546
+ function buildConditionalWriteQuery(table, operation, changes, options, returning) {
547
+ for (const key of [
548
+ "joins",
549
+ "groupBy",
550
+ "having",
551
+ "select",
552
+ "orderBy",
553
+ "limit",
554
+ "offset",
555
+ "lock"
556
+ ]) {
557
+ if (options[key] !== undefined)
558
+ throw new Error(`Conditional writes do not support ${key}.`);
559
+ }
560
+ const params = [];
561
+ const predicate = buildAdvancedWhereClause(table.name, options.where ?? {}, options.whereNodes ?? []);
562
+ if (!predicate.clause)
563
+ throw new Error("Conditional writes require an explicit predicate.");
564
+ if (returning !== undefined && currentSqlDialect().driver === "mysql")
565
+ throw new Error("Mutation RETURNING is not supported on MySQL.");
566
+ const columns = returning ?? [table.primaryKey];
567
+ if (!columns.length || columns.some((column) => !table.columns.includes(column)))
568
+ throw new Error("RETURNING columns must be nonempty declared table columns.");
569
+ const deletedAt = resolveSoftDeleteColumn(table);
570
+ const values = operation === "delete" && deletedAt ? { [deletedAt]: new Date } : changes;
571
+ let prefix;
572
+ if (operation === "update" || deletedAt) {
573
+ const entries = Object.entries(values).filter(([, value]) => value !== undefined);
574
+ if (!entries.length)
575
+ throw new Error("Conditional updates require at least one value.");
576
+ if (entries.some(([column]) => column === table.primaryKey || !table.columns.includes(column)))
577
+ throw new Error("Conditional updates require declared non-primary-key columns.");
578
+ const assignments = entries.map(([column, value]) => `${quoteIdentifier(column)} = ${pushParam(params, value)}`);
579
+ prefix = `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")}`;
580
+ } else
581
+ prefix = `DELETE FROM ${quoteIdentifier(table.name)}`;
582
+ const { clause } = buildQueryWhereClause(table, options, options.whereNodes ?? [], params);
583
+ return {
584
+ text: prefix + clause + returningSuffix(columns.map(quoteIdentifier).join(", ")),
585
+ params
586
+ };
587
+ }
514
588
  export {
515
589
  assertSafeProjectionExpression,
516
590
  buildAdvancedWhereClause,
591
+ buildConditionalWriteQuery,
517
592
  buildCountQuery,
518
593
  buildDeleteByIdQuery,
519
594
  buildGroupedCountQuery,
@@ -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
  }
@@ -355,6 +361,31 @@ function getDefinedColumnEntries(table, values, options = {}) {
355
361
  return [[column, value]];
356
362
  });
357
363
  }
364
+ function buildSelectLock(table, options) {
365
+ const lock = options.lock;
366
+ if (!lock)
367
+ return "";
368
+ const driver = currentSqlDialect().driver;
369
+ if (driver === "sqlite")
370
+ throw new Error("Row locks are not supported on SQLite; use an explicit write transaction.");
371
+ if (lock.mode !== "update" && lock.mode !== "share")
372
+ throw new TypeError("Invalid row lock mode.");
373
+ if (lock.wait !== undefined && !["wait", "nowait", "skipLocked"].includes(lock.wait))
374
+ throw new TypeError("Invalid row lock wait policy.");
375
+ if (options.groupBy || options.having)
376
+ throw new Error("Row locks cannot be combined with grouped/aggregate queries.");
377
+ let target = "";
378
+ if (lock.of !== undefined) {
379
+ if (driver !== "pgsql")
380
+ throw new Error("Row lock targets are supported only on PostgreSQL.");
381
+ const tables = [table.name, ...(options.joins ?? []).map((join) => join.table)];
382
+ if (!lock.of.length || lock.of.some((name) => !tables.includes(name)))
383
+ throw new TypeError("Row lock targets must name tables in this query.");
384
+ target = ` OF ${lock.of.map(quoteIdentifier).join(", ")}`;
385
+ }
386
+ const wait = lock.wait === "nowait" ? " NOWAIT" : lock.wait === "skipLocked" ? " SKIP LOCKED" : "";
387
+ return ` FOR ${lock.mode === "update" ? "UPDATE" : "SHARE"}${target}${wait}`;
388
+ }
358
389
  function buildSelectQuery(table, options = {}, whereNodes = []) {
359
390
  const params = [];
360
391
  const columns = buildSelectList(table, options.select, params);
@@ -365,8 +396,9 @@ function buildSelectQuery(table, options = {}, whereNodes = []) {
365
396
  const orderBy = buildOrderByClause(table.name, options.orderBy ?? table.defaultOrderBy);
366
397
  const limit = buildLimitClause(options.limit);
367
398
  const offset = buildOffsetClause(options.offset);
399
+ const lock = buildSelectLock(table, options);
368
400
  return {
369
- text: `SELECT ${columns} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${havingClause}${orderBy}${limit}${offset}`,
401
+ text: `SELECT ${columns} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${havingClause}${orderBy}${limit}${offset}${lock}`,
370
402
  params
371
403
  };
372
404
  }
@@ -535,6 +567,48 @@ function buildDeleteByIdQuery(table, id) {
535
567
  params
536
568
  };
537
569
  }
570
+ function buildConditionalWriteQuery(table, operation, changes, options, returning) {
571
+ for (const key of [
572
+ "joins",
573
+ "groupBy",
574
+ "having",
575
+ "select",
576
+ "orderBy",
577
+ "limit",
578
+ "offset",
579
+ "lock"
580
+ ]) {
581
+ if (options[key] !== undefined)
582
+ throw new Error(`Conditional writes do not support ${key}.`);
583
+ }
584
+ const params = [];
585
+ const predicate = buildAdvancedWhereClause(table.name, options.where ?? {}, options.whereNodes ?? []);
586
+ if (!predicate.clause)
587
+ throw new Error("Conditional writes require an explicit predicate.");
588
+ if (returning !== undefined && currentSqlDialect().driver === "mysql")
589
+ throw new Error("Mutation RETURNING is not supported on MySQL.");
590
+ const columns = returning ?? [table.primaryKey];
591
+ if (!columns.length || columns.some((column) => !table.columns.includes(column)))
592
+ throw new Error("RETURNING columns must be nonempty declared table columns.");
593
+ const deletedAt = resolveSoftDeleteColumn(table);
594
+ const values = operation === "delete" && deletedAt ? { [deletedAt]: new Date } : changes;
595
+ let prefix;
596
+ if (operation === "update" || deletedAt) {
597
+ const entries = Object.entries(values).filter(([, value]) => value !== undefined);
598
+ if (!entries.length)
599
+ throw new Error("Conditional updates require at least one value.");
600
+ if (entries.some(([column]) => column === table.primaryKey || !table.columns.includes(column)))
601
+ throw new Error("Conditional updates require declared non-primary-key columns.");
602
+ const assignments = entries.map(([column, value]) => `${quoteIdentifier(column)} = ${pushParam(params, value)}`);
603
+ prefix = `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")}`;
604
+ } else
605
+ prefix = `DELETE FROM ${quoteIdentifier(table.name)}`;
606
+ const { clause } = buildQueryWhereClause(table, options, options.whereNodes ?? [], params);
607
+ return {
608
+ text: prefix + clause + returningSuffix(columns.map(quoteIdentifier).join(", ")),
609
+ params
610
+ };
611
+ }
538
612
 
539
613
  // ../../src/core/database/relationships.ts
540
614
  function hasMany(definition) {
@@ -856,6 +930,20 @@ class RepositoryQuery {
856
930
  this.whereNodes.push({ kind: "or", where: input });
857
931
  return this;
858
932
  }
933
+ lockForUpdate(options = {}) {
934
+ this.queryOptions = {
935
+ ...this.queryOptions,
936
+ lock: { ...options, of: options.of ? [...options.of] : undefined, mode: "update" }
937
+ };
938
+ return this;
939
+ }
940
+ sharedLock(options = {}) {
941
+ this.queryOptions = {
942
+ ...this.queryOptions,
943
+ lock: { ...options, of: options.of ? [...options.of] : undefined, mode: "share" }
944
+ };
945
+ return this;
946
+ }
859
947
  orderBy(orderBy) {
860
948
  this.queryOptions = { ...this.queryOptions, orderBy };
861
949
  return this;
@@ -995,6 +1083,22 @@ class RepositoryQuery {
995
1083
  this.queryOptions = { ...this.queryOptions, onlyTrashed: true };
996
1084
  return this;
997
1085
  }
1086
+ async update(changes) {
1087
+ this.assertNoMutationEagerLoads();
1088
+ return this.repository.updateWhere(changes, this.buildOptions());
1089
+ }
1090
+ async updateReturning(changes, ...columns) {
1091
+ this.assertNoMutationEagerLoads();
1092
+ return this.repository.updateWhereReturning(changes, columns, this.buildOptions());
1093
+ }
1094
+ async delete() {
1095
+ this.assertNoMutationEagerLoads();
1096
+ return this.repository.deleteWhere(this.buildOptions());
1097
+ }
1098
+ assertNoMutationEagerLoads() {
1099
+ if (this.eagerLoads.length)
1100
+ throw new Error("Conditional writes do not support eager loading.");
1101
+ }
998
1102
  async get() {
999
1103
  const rows = await this.repository.findAll(this.buildOptions());
1000
1104
  return await this.attach(rows);
@@ -1059,6 +1163,20 @@ class RepositoryQuery {
1059
1163
  const page = await this.repository.cursorPaginate({ ...this.buildOptions(), ...options });
1060
1164
  return { ...page, data: await this.attach(page.data) };
1061
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
+ }
1062
1180
  getOptions() {
1063
1181
  return this.buildOptions();
1064
1182
  }
@@ -340,6 +340,13 @@ class Blueprint {
340
340
  this.columns.push(column);
341
341
  return column;
342
342
  }
343
+ bigId(name = "id") {
344
+ const column = new ColumnDefinition(name, "bigId");
345
+ column.primary();
346
+ column.autoIncrement = true;
347
+ this.columns.push(column);
348
+ return column;
349
+ }
343
350
  string(name, length) {
344
351
  if (length !== undefined && (!Number.isSafeInteger(length) || length <= 0)) {
345
352
  throw new Error("String length must be a positive safe integer.");
@@ -486,6 +493,8 @@ init_dialect();
486
493
  // ../../src/core/database/schema/grammars/grammar.ts
487
494
  function compileColumnType(driver, column) {
488
495
  switch (column.kind) {
496
+ case "bigId":
497
+ return driver === "pgsql" ? "BIGSERIAL" : compileIdType(driver);
489
498
  case "id":
490
499
  return compileIdType(driver);
491
500
  case "string":
@@ -69,7 +69,7 @@ function createSqliteConnection(filename) {
69
69
  const transactions = new WeakSet;
70
70
  function execute(query, params) {
71
71
  const statement = db.query(query);
72
- const args = [...params];
72
+ const args = params.map((value) => value instanceof Date ? value.toISOString() : value);
73
73
  if (isRowReturning(query))
74
74
  return statement.all(...args);
75
75
  statement.run(...args);
@@ -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
  }
@@ -4463,6 +4508,31 @@ function getDefinedColumnEntries(table, values, options = {}) {
4463
4508
  return [[column, value]];
4464
4509
  });
4465
4510
  }
4511
+ function buildSelectLock(table, options) {
4512
+ const lock = options.lock;
4513
+ if (!lock)
4514
+ return "";
4515
+ const driver = currentSqlDialect().driver;
4516
+ if (driver === "sqlite")
4517
+ throw new Error("Row locks are not supported on SQLite; use an explicit write transaction.");
4518
+ if (lock.mode !== "update" && lock.mode !== "share")
4519
+ throw new TypeError("Invalid row lock mode.");
4520
+ if (lock.wait !== undefined && !["wait", "nowait", "skipLocked"].includes(lock.wait))
4521
+ throw new TypeError("Invalid row lock wait policy.");
4522
+ if (options.groupBy || options.having)
4523
+ throw new Error("Row locks cannot be combined with grouped/aggregate queries.");
4524
+ let target = "";
4525
+ if (lock.of !== undefined) {
4526
+ if (driver !== "pgsql")
4527
+ throw new Error("Row lock targets are supported only on PostgreSQL.");
4528
+ const tables = [table.name, ...(options.joins ?? []).map((join) => join.table)];
4529
+ if (!lock.of.length || lock.of.some((name) => !tables.includes(name)))
4530
+ throw new TypeError("Row lock targets must name tables in this query.");
4531
+ target = ` OF ${lock.of.map(quoteIdentifier).join(", ")}`;
4532
+ }
4533
+ const wait = lock.wait === "nowait" ? " NOWAIT" : lock.wait === "skipLocked" ? " SKIP LOCKED" : "";
4534
+ return ` FOR ${lock.mode === "update" ? "UPDATE" : "SHARE"}${target}${wait}`;
4535
+ }
4466
4536
  function buildSelectQuery(table, options = {}, whereNodes = []) {
4467
4537
  const params = [];
4468
4538
  const columns = buildSelectList(table, options.select, params);
@@ -4473,8 +4543,9 @@ function buildSelectQuery(table, options = {}, whereNodes = []) {
4473
4543
  const orderBy = buildOrderByClause(table.name, options.orderBy ?? table.defaultOrderBy);
4474
4544
  const limit = buildLimitClause(options.limit);
4475
4545
  const offset = buildOffsetClause(options.offset);
4546
+ const lock = buildSelectLock(table, options);
4476
4547
  return {
4477
- text: `SELECT ${columns} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${havingClause}${orderBy}${limit}${offset}`,
4548
+ text: `SELECT ${columns} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${havingClause}${orderBy}${limit}${offset}${lock}`,
4478
4549
  params
4479
4550
  };
4480
4551
  }
@@ -4643,6 +4714,48 @@ function buildDeleteByIdQuery(table, id) {
4643
4714
  params
4644
4715
  };
4645
4716
  }
4717
+ function buildConditionalWriteQuery(table, operation, changes, options, returning) {
4718
+ for (const key of [
4719
+ "joins",
4720
+ "groupBy",
4721
+ "having",
4722
+ "select",
4723
+ "orderBy",
4724
+ "limit",
4725
+ "offset",
4726
+ "lock"
4727
+ ]) {
4728
+ if (options[key] !== undefined)
4729
+ throw new Error(`Conditional writes do not support ${key}.`);
4730
+ }
4731
+ const params = [];
4732
+ const predicate = buildAdvancedWhereClause(table.name, options.where ?? {}, options.whereNodes ?? []);
4733
+ if (!predicate.clause)
4734
+ throw new Error("Conditional writes require an explicit predicate.");
4735
+ if (returning !== undefined && currentSqlDialect().driver === "mysql")
4736
+ throw new Error("Mutation RETURNING is not supported on MySQL.");
4737
+ const columns = returning ?? [table.primaryKey];
4738
+ if (!columns.length || columns.some((column) => !table.columns.includes(column)))
4739
+ throw new Error("RETURNING columns must be nonempty declared table columns.");
4740
+ const deletedAt = resolveSoftDeleteColumn(table);
4741
+ const values = operation === "delete" && deletedAt ? { [deletedAt]: new Date } : changes;
4742
+ let prefix;
4743
+ if (operation === "update" || deletedAt) {
4744
+ const entries = Object.entries(values).filter(([, value]) => value !== undefined);
4745
+ if (!entries.length)
4746
+ throw new Error("Conditional updates require at least one value.");
4747
+ if (entries.some(([column]) => column === table.primaryKey || !table.columns.includes(column)))
4748
+ throw new Error("Conditional updates require declared non-primary-key columns.");
4749
+ const assignments = entries.map(([column, value]) => `${quoteIdentifier(column)} = ${pushParam(params, value)}`);
4750
+ prefix = `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")}`;
4751
+ } else
4752
+ prefix = `DELETE FROM ${quoteIdentifier(table.name)}`;
4753
+ const { clause } = buildQueryWhereClause(table, options, options.whereNodes ?? [], params);
4754
+ return {
4755
+ text: prefix + clause + returningSuffix(columns.map(quoteIdentifier).join(", ")),
4756
+ params
4757
+ };
4758
+ }
4646
4759
 
4647
4760
  // ../../src/core/database/relationships.ts
4648
4761
  function hasMany(definition) {
@@ -4991,6 +5104,20 @@ class RepositoryQuery {
4991
5104
  this.whereNodes.push({ kind: "or", where: input });
4992
5105
  return this;
4993
5106
  }
5107
+ lockForUpdate(options = {}) {
5108
+ this.queryOptions = {
5109
+ ...this.queryOptions,
5110
+ lock: { ...options, of: options.of ? [...options.of] : undefined, mode: "update" }
5111
+ };
5112
+ return this;
5113
+ }
5114
+ sharedLock(options = {}) {
5115
+ this.queryOptions = {
5116
+ ...this.queryOptions,
5117
+ lock: { ...options, of: options.of ? [...options.of] : undefined, mode: "share" }
5118
+ };
5119
+ return this;
5120
+ }
4994
5121
  orderBy(orderBy) {
4995
5122
  this.queryOptions = { ...this.queryOptions, orderBy };
4996
5123
  return this;
@@ -5130,6 +5257,22 @@ class RepositoryQuery {
5130
5257
  this.queryOptions = { ...this.queryOptions, onlyTrashed: true };
5131
5258
  return this;
5132
5259
  }
5260
+ async update(changes) {
5261
+ this.assertNoMutationEagerLoads();
5262
+ return this.repository.updateWhere(changes, this.buildOptions());
5263
+ }
5264
+ async updateReturning(changes, ...columns) {
5265
+ this.assertNoMutationEagerLoads();
5266
+ return this.repository.updateWhereReturning(changes, columns, this.buildOptions());
5267
+ }
5268
+ async delete() {
5269
+ this.assertNoMutationEagerLoads();
5270
+ return this.repository.deleteWhere(this.buildOptions());
5271
+ }
5272
+ assertNoMutationEagerLoads() {
5273
+ if (this.eagerLoads.length)
5274
+ throw new Error("Conditional writes do not support eager loading.");
5275
+ }
5133
5276
  async get() {
5134
5277
  const rows = await this.repository.findAll(this.buildOptions());
5135
5278
  return await this.attach(rows);
@@ -5194,6 +5337,20 @@ class RepositoryQuery {
5194
5337
  const page = await this.repository.cursorPaginate({ ...this.buildOptions(), ...options });
5195
5338
  return { ...page, data: await this.attach(page.data) };
5196
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
+ }
5197
5354
  getOptions() {
5198
5355
  return this.buildOptions();
5199
5356
  }
@@ -5335,11 +5492,16 @@ class BaseRepository {
5335
5492
  return runInTransaction(operation);
5336
5493
  }
5337
5494
  async count(options = {}) {
5495
+ if (options.lock)
5496
+ throw new Error("Row locks require a row selection, not count().");
5338
5497
  const { whereNodes, where, ...rest } = options;
5339
5498
  return await this.countWhere(where ?? {}, rest, whereNodes ?? []);
5340
5499
  }
5341
5500
  async findAll(options = {}) {
5342
5501
  return await withDatabaseErrorHandling(async () => {
5502
+ if (options.lock && (!hasActiveTransaction() || this.connection !== repositoryConnection && this.connection !== getActiveDatabaseConnection(repositoryConnection))) {
5503
+ throw new Error("Row locks require the framework active transaction connection.");
5504
+ }
5343
5505
  const { whereNodes, ...queryOptions } = options;
5344
5506
  const { text, params } = buildSelectQuery(this.table, queryOptions, whereNodes ?? []);
5345
5507
  return await this.connection.unsafe(text, params);
@@ -5454,6 +5616,65 @@ class BaseRepository {
5454
5616
  }
5455
5617
  };
5456
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
+ }
5457
5678
  async findById(id) {
5458
5679
  return await this.firstOrNull({
5459
5680
  [this.table.primaryKey]: id
@@ -5509,6 +5730,28 @@ class BaseRepository {
5509
5730
  return entity;
5510
5731
  }));
5511
5732
  }
5733
+ async updateWhere(changes, options) {
5734
+ return this.executeConditionalWrite("update", changes, options);
5735
+ }
5736
+ async deleteWhere(options) {
5737
+ return this.executeConditionalWrite("delete", {}, options);
5738
+ }
5739
+ async updateWhereReturning(changes, columns, options) {
5740
+ const { text, params } = buildConditionalWriteQuery(this.table, "update", changes, options, columns);
5741
+ return withDatabaseErrorHandling(() => this.connection.unsafe(text, params));
5742
+ }
5743
+ async executeConditionalWrite(operation, changes, options) {
5744
+ const { text, params } = buildConditionalWriteQuery(this.table, operation, changes, options);
5745
+ return withDatabaseErrorHandling(async () => {
5746
+ const rows = await this.connection.unsafe(text, params);
5747
+ if (currentSqlDialect().driver !== "mysql")
5748
+ return rows.length;
5749
+ const affected = rows[0]?.affectedRows;
5750
+ if (!Number.isSafeInteger(affected) || affected === undefined || affected < 0)
5751
+ throw new Error("MySQL conditional writes require affectedRows metadata from the framework adapter.");
5752
+ return affected;
5753
+ });
5754
+ }
5512
5755
  async updateById(id, changes) {
5513
5756
  this.assertTransactionalEvent("updated");
5514
5757
  return await this.runModelMutation("updated", () => withDatabaseErrorHandling(async () => {
@@ -7119,8 +7362,13 @@ function applyTimestampsOnUpdate(columns, values, enabled) {
7119
7362
 
7120
7363
  class ModelProjection {
7121
7364
  load;
7122
- constructor(load) {
7365
+ page;
7366
+ constructor(load, page) {
7123
7367
  this.load = load;
7368
+ this.page = page;
7369
+ }
7370
+ keysetPaginate(options) {
7371
+ return this.page(options);
7124
7372
  }
7125
7373
  get() {
7126
7374
  return this.load();
@@ -7170,8 +7418,22 @@ class ModelQuery {
7170
7418
  return new ModelProjection(async (limit) => {
7171
7419
  const rows = await this.query.project(columns, limit);
7172
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
+ };
7173
7427
  });
7174
7428
  }
7429
+ lockForUpdate(options = {}) {
7430
+ this.query.lockForUpdate(options);
7431
+ return this;
7432
+ }
7433
+ sharedLock(options = {}) {
7434
+ this.query.sharedLock(options);
7435
+ return this;
7436
+ }
7175
7437
  orderBy(orderBy) {
7176
7438
  this.query.orderBy(orderBy);
7177
7439
  return this;
@@ -7279,6 +7541,10 @@ class ModelQuery {
7279
7541
  async get() {
7280
7542
  return await this.hydrateRows(await this.query.get());
7281
7543
  }
7544
+ async keysetPaginate(options) {
7545
+ const page = await this.query.keysetPaginate(options);
7546
+ return { ...page, data: await this.hydrateRows(page.data) };
7547
+ }
7282
7548
  async hydrateRows(rows) {
7283
7549
  const statics = modelStatics(this.modelClass);
7284
7550
  const models = [];
@@ -7562,6 +7828,9 @@ class Model {
7562
7828
  return await callback(rows.map((row) => statics.newFromRecord(row, true)));
7563
7829
  });
7564
7830
  }
7831
+ static keysetPaginate(options) {
7832
+ return Model.query.call(this).keysetPaginate(options);
7833
+ }
7565
7834
  static async cursorPaginate(options) {
7566
7835
  const statics = modelStatics(this);
7567
7836
  ensureBooted(this);
@@ -8200,6 +8469,13 @@ class Blueprint {
8200
8469
  this.columns.push(column);
8201
8470
  return column;
8202
8471
  }
8472
+ bigId(name = "id") {
8473
+ const column = new ColumnDefinition(name, "bigId");
8474
+ column.primary();
8475
+ column.autoIncrement = true;
8476
+ this.columns.push(column);
8477
+ return column;
8478
+ }
8203
8479
  string(name, length) {
8204
8480
  if (length !== undefined && (!Number.isSafeInteger(length) || length <= 0)) {
8205
8481
  throw new Error("String length must be a positive safe integer.");
@@ -8346,6 +8622,8 @@ init_dialect();
8346
8622
  // ../../src/core/database/schema/grammars/grammar.ts
8347
8623
  function compileColumnType(driver, column) {
8348
8624
  switch (column.kind) {
8625
+ case "bigId":
8626
+ return driver === "pgsql" ? "BIGSERIAL" : compileIdType(driver);
8349
8627
  case "id":
8350
8628
  return compileIdType(driver);
8351
8629
  case "string":
@@ -8781,7 +9059,7 @@ function createSqliteConnection(filename) {
8781
9059
  const transactions = new WeakSet;
8782
9060
  function execute(query, params) {
8783
9061
  const statement = db.query(query);
8784
- const args = [...params];
9062
+ const args = params.map((value) => value instanceof Date ? value.toISOString() : value);
8785
9063
  if (isRowReturning(query))
8786
9064
  return statement.all(...args);
8787
9065
  statement.run(...args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/core",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "description": "Strata Bun framework public API",
5
5
  "type": "module",
6
6
  "license": "MIT",