@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 +8 -0
- package/dist/core/database/baseRepository.d.ts +7 -0
- package/dist/core/database/keyset.d.ts +7 -0
- package/dist/core/database/model.d.ts +17 -2
- package/dist/core/database/query.d.ts +6 -1
- package/dist/core/database/repositoryQuery.d.ts +16 -2
- package/dist/core/database/schema/blueprint.d.ts +2 -0
- package/dist/core/database/schema/columnDefinition.d.ts +1 -1
- package/dist/core/database/types.d.ts +15 -1
- package/dist/core/pagination/index.d.ts +21 -0
- package/dist/entries/database/query.js +76 -1
- package/dist/entries/database/repositoryQuery.js +119 -1
- package/dist/entries/database/schema.js +9 -0
- package/dist/entries/database/sqliteConnection.js +1 -1
- package/dist/framework/public-api.d.ts +1 -1
- package/dist/index.js +281 -3
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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);
|