@jskit-ai/crud-core 0.1.31 → 0.1.32

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/crud-core",
4
- version: "0.1.31",
4
+ version: "0.1.32",
5
5
  kind: "runtime",
6
6
  description: "Shared CRUD helpers used by CRUD modules.",
7
7
  dependsOn: [
@@ -26,7 +26,7 @@ export default Object.freeze({
26
26
  mutations: {
27
27
  dependencies: {
28
28
  runtime: {
29
- "@jskit-ai/crud-core": "0.1.31"
29
+ "@jskit-ai/crud-core": "0.1.32"
30
30
  },
31
31
  dev: {}
32
32
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-core",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -18,6 +18,7 @@
18
18
  "./server/createCrudRepositoryFromResource": "./src/server/createCrudRepositoryFromResource.js",
19
19
  "./server/lookupProviders": "./src/server/lookupProviders.js",
20
20
  "./server/serviceEvents": "./src/server/serviceEvents.js",
21
+ "./server/serviceMethods": "./src/server/serviceMethods.js",
21
22
  "./server/fieldAccess": "./src/server/fieldAccess.js",
22
23
  "./server/createCrudServiceFromResource": "./src/server/createCrudServiceFromResource.js",
23
24
  "./server/crudModuleConfig": "./src/server/crudModuleConfig.js",
@@ -25,11 +26,11 @@
25
26
  },
26
27
  "dependencies": {
27
28
  "@tanstack/vue-query": "^5.90.5",
28
- "@jskit-ai/kernel": "0.1.23",
29
- "@jskit-ai/realtime": "0.1.22",
30
- "@jskit-ai/shell-web": "0.1.22",
31
- "@jskit-ai/users-core": "0.1.32",
32
- "@jskit-ai/users-web": "0.1.37",
29
+ "@jskit-ai/kernel": "0.1.24",
30
+ "@jskit-ai/realtime": "0.1.23",
31
+ "@jskit-ai/shell-web": "0.1.23",
32
+ "@jskit-ai/users-core": "0.1.33",
33
+ "@jskit-ai/users-web": "0.1.38",
33
34
  "typebox": "^1.0.81"
34
35
  }
35
36
  }
@@ -1,12 +1,16 @@
1
- import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
- import { requireCrudNamespace } from "../shared/crudNamespaceSupport.js";
3
- import { createCrudFieldAccessRuntime } from "./fieldAccess.js";
4
1
  import { createCrudServiceEvents } from "./serviceEvents.js";
2
+ import {
3
+ createCrudServiceRuntime,
4
+ crudServiceListRecords,
5
+ crudServiceGetRecord,
6
+ crudServiceCreateRecord,
7
+ crudServiceUpdateRecord,
8
+ crudServiceDeleteRecord
9
+ } from "./serviceMethods.js";
5
10
 
6
11
  function createCrudServiceFromResource(resource = {}, { context = "crudService" } = {}) {
7
- const namespace = requireCrudNamespace(resource?.resource, { context: `${context} resource.resource` });
12
+ const runtime = createCrudServiceRuntime(resource, { context });
8
13
  const baseServiceEvents = createCrudServiceEvents(resource, { context });
9
- const fieldAccessRuntime = createCrudFieldAccessRuntime(resource, { context });
10
14
 
11
15
  function createBaseService({ repository, fieldAccess = {} } = {}) {
12
16
  if (!repository) {
@@ -14,73 +18,23 @@ function createCrudServiceFromResource(resource = {}, { context = "crudService"
14
18
  }
15
19
 
16
20
  async function listRecords(query = {}, options = {}) {
17
- const result = await repository.list(query, options);
18
- return fieldAccessRuntime.filterReadableListResult(result, fieldAccess, {
19
- action: "list",
20
- query,
21
- options,
22
- context: options?.context
23
- });
21
+ return crudServiceListRecords(runtime, repository, fieldAccess, query, options);
24
22
  }
25
23
 
26
24
  async function getRecord(recordId, options = {}) {
27
- const record = await repository.findById(recordId, options);
28
- if (!record) {
29
- throw new AppError(404, "Record not found.");
30
- }
31
-
32
- return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
33
- action: "view",
34
- recordId,
35
- options,
36
- context: options?.context
37
- });
25
+ return crudServiceGetRecord(runtime, repository, fieldAccess, recordId, options);
38
26
  }
39
27
 
40
28
  async function createRecord(payload = {}, options = {}) {
41
- const writablePayload = await fieldAccessRuntime.enforceWritablePayload(payload, fieldAccess, {
42
- action: "create",
43
- payload,
44
- options,
45
- context: options?.context
46
- });
47
- const record = await repository.create(writablePayload, options);
48
- if (!record) {
49
- throw new Error(`${namespace}Service could not load the created record.`);
50
- }
51
- return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
52
- action: "create",
53
- options,
54
- context: options?.context
55
- });
29
+ return crudServiceCreateRecord(runtime, repository, fieldAccess, payload, options);
56
30
  }
57
31
 
58
32
  async function updateRecord(recordId, payload = {}, options = {}) {
59
- const writablePayload = await fieldAccessRuntime.enforceWritablePayload(payload, fieldAccess, {
60
- action: "update",
61
- recordId,
62
- payload,
63
- options,
64
- context: options?.context
65
- });
66
- const record = await repository.updateById(recordId, writablePayload, options);
67
- if (!record) {
68
- throw new AppError(404, "Record not found.");
69
- }
70
- return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
71
- action: "update",
72
- recordId,
73
- options,
74
- context: options?.context
75
- });
33
+ return crudServiceUpdateRecord(runtime, repository, fieldAccess, recordId, payload, options);
76
34
  }
77
35
 
78
36
  async function deleteRecord(recordId, options = {}) {
79
- const deleted = await repository.deleteById(recordId, options);
80
- if (!deleted) {
81
- throw new AppError(404, "Record not found.");
82
- }
83
- return deleted;
37
+ return crudServiceDeleteRecord(runtime, repository, fieldAccess, recordId, options);
84
38
  }
85
39
 
86
40
  return Object.freeze({
@@ -1,7 +1,11 @@
1
1
  import { Type } from "typebox";
2
- import { normalizeObjectInput } from "@jskit-ai/kernel/shared/validators";
2
+ import {
3
+ normalizeObjectInput,
4
+ positiveIntegerValidator,
5
+ cursorPaginationQueryValidator
6
+ } from "@jskit-ai/kernel/shared/validators";
3
7
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
- import { resolveCrudLookupFieldKeys } from "@jskit-ai/kernel/shared/support/crudLookup";
8
+ import { resolveCrudParentFilterKeys as resolveSharedCrudParentFilterKeys } from "@jskit-ai/kernel/shared/support/crudLookup";
5
9
 
6
10
  const listSearchQueryValidator = Object.freeze({
7
11
  schema: Type.Object(
@@ -41,16 +45,59 @@ const lookupIncludeQueryValidator = Object.freeze({
41
45
  }
42
46
  });
43
47
 
44
- function resolveCrudParentFilterKeys(resource = {}) {
45
- const createSchemaProperties = resource?.operations?.create?.bodyValidator?.schema?.properties;
46
- const allowedKeys = createSchemaProperties && typeof createSchemaProperties === "object" && !Array.isArray(createSchemaProperties)
47
- ? Object.keys(createSchemaProperties)
48
- : [];
49
- return resolveCrudLookupFieldKeys(resource, {
50
- allowKeys: allowedKeys
48
+ function resolveCrudListUsesOrderedCursor(list = {}) {
49
+ const orderBy = Array.isArray(list?.orderBy) ? list.orderBy : [];
50
+ for (const entry of orderBy) {
51
+ if (typeof entry === "string" && normalizeText(entry)) {
52
+ return true;
53
+ }
54
+ if (entry && typeof entry === "object" && !Array.isArray(entry) && normalizeText(entry.column)) {
55
+ return true;
56
+ }
57
+ }
58
+
59
+ return false;
60
+ }
61
+
62
+ function createCrudCursorPaginationQueryValidator(list = {}) {
63
+ if (resolveCrudListUsesOrderedCursor(list) !== true) {
64
+ return cursorPaginationQueryValidator;
65
+ }
66
+
67
+ return Object.freeze({
68
+ schema: Type.Object(
69
+ {
70
+ cursor: Type.Optional(
71
+ Type.Union([
72
+ positiveIntegerValidator.schema,
73
+ Type.String({ minLength: 1 })
74
+ ])
75
+ ),
76
+ limit: Type.Optional(positiveIntegerValidator.schema)
77
+ },
78
+ { additionalProperties: false }
79
+ ),
80
+ normalize(payload = {}) {
81
+ const source = normalizeObjectInput(payload);
82
+ const normalized = {};
83
+
84
+ if (Object.hasOwn(source, "cursor")) {
85
+ normalized.cursor = normalizeText(source.cursor);
86
+ }
87
+
88
+ if (Object.hasOwn(source, "limit")) {
89
+ normalized.limit = positiveIntegerValidator.normalize(source.limit);
90
+ }
91
+
92
+ return normalized;
93
+ }
51
94
  });
52
95
  }
53
96
 
97
+ function resolveCrudParentFilterKeys(resource = {}) {
98
+ return resolveSharedCrudParentFilterKeys(resource);
99
+ }
100
+
54
101
  function createCrudParentFilterQueryValidator(resource = {}) {
55
102
  const keys = resolveCrudParentFilterKeys(resource);
56
103
  const schemaProperties = {};
@@ -80,6 +127,7 @@ function createCrudParentFilterQueryValidator(resource = {}) {
80
127
  }
81
128
 
82
129
  export {
130
+ createCrudCursorPaginationQueryValidator,
83
131
  listSearchQueryValidator,
84
132
  lookupIncludeQueryValidator,
85
133
  resolveCrudParentFilterKeys,
@@ -1,5 +1,6 @@
1
1
  import { toInsertDateTime } from "@jskit-ai/database-runtime/shared";
2
2
  import { applyVisibility, applyVisibilityOwners } from "@jskit-ai/database-runtime/shared/visibility";
3
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
3
4
  import { normalizeText, normalizeUniqueTextList } from "@jskit-ai/kernel/shared/support/normalize";
4
5
  import { Check, Errors } from "typebox/value";
5
6
  import {
@@ -20,6 +21,14 @@ import {
20
21
  hydrateCrudLookupRecords
21
22
  } from "./lookupHydration.js";
22
23
 
24
+ const LIST_ORDER_DIRECTION_ASC = "asc";
25
+ const LIST_ORDER_DIRECTION_DESC = "desc";
26
+ const LIST_ORDER_NULLS_FIRST = "first";
27
+ const LIST_ORDER_NULLS_LAST = "last";
28
+ const ORDERED_LIST_CURSOR_VALUE_TYPE_KEY = "__jskitCursorValueType";
29
+ const ORDERED_LIST_CURSOR_VALUE_KEY = "value";
30
+ const ORDERED_LIST_CURSOR_VALUE_TYPE_DATE = "date";
31
+
23
32
  function resolveRepositoryDefaults(resource = {}, repositoryMapping = {}) {
24
33
  const resourceName = normalizeText(resource.resource);
25
34
  const tableName = normalizeText(resource.tableName) || resourceName;
@@ -59,7 +68,80 @@ function normalizeSearchColumns(searchColumns = [], fallbackColumns = []) {
59
68
  );
60
69
  }
61
70
 
62
- function resolveListRuntimeConfig(list = {}, fallbackSearchColumns = []) {
71
+ function normalizeListOrderDirection(value = LIST_ORDER_DIRECTION_ASC) {
72
+ const normalized = normalizeText(value).toLowerCase();
73
+ if (!normalized) {
74
+ return LIST_ORDER_DIRECTION_ASC;
75
+ }
76
+ if (normalized === LIST_ORDER_DIRECTION_ASC || normalized === LIST_ORDER_DIRECTION_DESC) {
77
+ return normalized;
78
+ }
79
+
80
+ throw new TypeError(`crudRepository list.orderBy direction must be "${LIST_ORDER_DIRECTION_ASC}" or "${LIST_ORDER_DIRECTION_DESC}".`);
81
+ }
82
+
83
+ function normalizeListOrderNulls(value = LIST_ORDER_NULLS_LAST) {
84
+ const normalized = normalizeText(value).toLowerCase();
85
+ if (!normalized) {
86
+ return LIST_ORDER_NULLS_LAST;
87
+ }
88
+ if (normalized === LIST_ORDER_NULLS_FIRST || normalized === LIST_ORDER_NULLS_LAST) {
89
+ return normalized;
90
+ }
91
+
92
+ throw new TypeError(`crudRepository list.orderBy nulls must be "${LIST_ORDER_NULLS_FIRST}" or "${LIST_ORDER_NULLS_LAST}".`);
93
+ }
94
+
95
+ function normalizeListOrderBy(orderBy = [], { idColumn = "id" } = {}) {
96
+ const sourceEntries = Array.isArray(orderBy)
97
+ ? orderBy
98
+ : orderBy === null || orderBy === undefined
99
+ ? []
100
+ : [orderBy];
101
+ const normalizedIdColumn = normalizeText(idColumn) || "id";
102
+ const normalizedOrderBy = [];
103
+ const seenColumns = new Set();
104
+
105
+ for (const rawEntry of sourceEntries) {
106
+ const sourceEntry = typeof rawEntry === "string"
107
+ ? { column: rawEntry }
108
+ : rawEntry;
109
+ if (!sourceEntry || typeof sourceEntry !== "object" || Array.isArray(sourceEntry)) {
110
+ throw new TypeError("crudRepository list.orderBy entries must be objects or column strings.");
111
+ }
112
+
113
+ const column = normalizeText(sourceEntry.column);
114
+ if (!column) {
115
+ throw new TypeError("crudRepository list.orderBy entries require column.");
116
+ }
117
+ if (seenColumns.has(column)) {
118
+ continue;
119
+ }
120
+
121
+ seenColumns.add(column);
122
+ normalizedOrderBy.push(
123
+ Object.freeze({
124
+ column,
125
+ direction: normalizeListOrderDirection(sourceEntry.direction),
126
+ nulls: normalizeListOrderNulls(sourceEntry.nulls)
127
+ })
128
+ );
129
+ }
130
+
131
+ if (normalizedOrderBy.length > 0 && !seenColumns.has(normalizedIdColumn)) {
132
+ normalizedOrderBy.push(
133
+ Object.freeze({
134
+ column: normalizedIdColumn,
135
+ direction: normalizedOrderBy[normalizedOrderBy.length - 1].direction,
136
+ nulls: LIST_ORDER_NULLS_LAST
137
+ })
138
+ );
139
+ }
140
+
141
+ return Object.freeze(normalizedOrderBy);
142
+ }
143
+
144
+ function resolveListRuntimeConfig(list = {}, fallbackSearchColumns = [], { idColumn = "id" } = {}) {
63
145
  const parsedMaxLimit = Number(list?.maxLimit);
64
146
  const normalizedMaxLimit = Number.isInteger(parsedMaxLimit) && parsedMaxLimit > 0
65
147
  ? parsedMaxLimit
@@ -72,7 +154,176 @@ function resolveListRuntimeConfig(list = {}, fallbackSearchColumns = []) {
72
154
  return Object.freeze({
73
155
  defaultLimit: normalizedDefaultLimit,
74
156
  maxLimit: normalizedMaxLimit,
75
- searchColumns: normalizeSearchColumns(list?.searchColumns, fallbackSearchColumns)
157
+ searchColumns: normalizeSearchColumns(list?.searchColumns, fallbackSearchColumns),
158
+ orderBy: normalizeListOrderBy(list?.orderBy, { idColumn })
159
+ });
160
+ }
161
+
162
+ function encodeOrderedListCursorValue(value = null) {
163
+ if (value instanceof Date) {
164
+ return {
165
+ [ORDERED_LIST_CURSOR_VALUE_TYPE_KEY]: ORDERED_LIST_CURSOR_VALUE_TYPE_DATE,
166
+ [ORDERED_LIST_CURSOR_VALUE_KEY]: value.toISOString()
167
+ };
168
+ }
169
+
170
+ return value === undefined ? null : value;
171
+ }
172
+
173
+ function decodeOrderedListCursorValue(value = null) {
174
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
175
+ return value === undefined ? null : value;
176
+ }
177
+
178
+ const valueType = normalizeText(value[ORDERED_LIST_CURSOR_VALUE_TYPE_KEY]).toLowerCase();
179
+ if (!valueType) {
180
+ return value;
181
+ }
182
+ if (valueType !== ORDERED_LIST_CURSOR_VALUE_TYPE_DATE) {
183
+ return value;
184
+ }
185
+
186
+ const normalizedValue = normalizeText(value[ORDERED_LIST_CURSOR_VALUE_KEY]);
187
+ if (!normalizedValue) {
188
+ throw new TypeError("Ordered list cursor date values require a non-empty value.");
189
+ }
190
+
191
+ const date = new Date(normalizedValue);
192
+ if (Number.isNaN(date.getTime())) {
193
+ throw new TypeError("Ordered list cursor date values must be valid dates.");
194
+ }
195
+
196
+ return date;
197
+ }
198
+
199
+ function encodeOrderedListCursor(row = null, orderBy = []) {
200
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
201
+ return null;
202
+ }
203
+
204
+ const normalizedOrderBy = Array.isArray(orderBy) ? orderBy : [];
205
+ if (normalizedOrderBy.length < 1) {
206
+ return null;
207
+ }
208
+
209
+ const values = normalizedOrderBy.map(({ column }) => (
210
+ Object.hasOwn(row, column) && row[column] !== undefined
211
+ ? encodeOrderedListCursorValue(row[column])
212
+ : null
213
+ ));
214
+
215
+ return Buffer.from(JSON.stringify({ values }), "utf8").toString("base64url");
216
+ }
217
+
218
+ function decodeOrderedListCursor(cursor = "", orderBy = []) {
219
+ const normalizedCursor = normalizeText(cursor);
220
+ const normalizedOrderBy = Array.isArray(orderBy) ? orderBy : [];
221
+ if (!normalizedCursor || normalizedOrderBy.length < 1) {
222
+ return null;
223
+ }
224
+
225
+ try {
226
+ const decoded = Buffer.from(normalizedCursor, "base64url").toString("utf8");
227
+ const payload = JSON.parse(decoded);
228
+ const values = Array.isArray(payload?.values) ? payload.values : null;
229
+ if (!values || values.length !== normalizedOrderBy.length) {
230
+ throw new AppError(400, "Invalid cursor.", {
231
+ code: "INVALID_CURSOR"
232
+ });
233
+ }
234
+
235
+ return values.map((value) => decodeOrderedListCursorValue(value));
236
+ } catch (error) {
237
+ if (error instanceof AppError) {
238
+ throw error;
239
+ }
240
+
241
+ throw new AppError(400, "Invalid cursor.", {
242
+ code: "INVALID_CURSOR"
243
+ });
244
+ }
245
+ }
246
+
247
+ function applyOrderedListCursorEquality(query, descriptor = {}, value = null) {
248
+ if (value === null) {
249
+ query.whereNull(descriptor.column);
250
+ return;
251
+ }
252
+
253
+ query.where(descriptor.column, value);
254
+ }
255
+
256
+ function applyOrderedListCursorAfterBranch(query, descriptor = {}, value = null) {
257
+ const operator = descriptor.direction === LIST_ORDER_DIRECTION_DESC ? "<" : ">";
258
+
259
+ if (value === null) {
260
+ if (descriptor.nulls === LIST_ORDER_NULLS_FIRST) {
261
+ query.whereNotNull(descriptor.column);
262
+ return true;
263
+ }
264
+
265
+ return false;
266
+ }
267
+
268
+ if (descriptor.nulls === LIST_ORDER_NULLS_LAST) {
269
+ query.where((branchQuery) => {
270
+ branchQuery.where(descriptor.column, operator, value);
271
+ branchQuery.orWhereNull(descriptor.column);
272
+ });
273
+ return true;
274
+ }
275
+
276
+ query.where(descriptor.column, operator, value);
277
+ return true;
278
+ }
279
+
280
+ function canApplyOrderedListCursorAfterBranch(descriptor = {}, value = null) {
281
+ return !(value === null && descriptor.nulls === LIST_ORDER_NULLS_LAST);
282
+ }
283
+
284
+ function appendOrderedListCursorBranches(query, orderBy = [], cursorValues = [], index = 0, { useOr = false } = {}) {
285
+ const descriptor = orderBy[index];
286
+ if (!descriptor) {
287
+ return false;
288
+ }
289
+
290
+ let addedBranch = false;
291
+ const currentValue = cursorValues[index] ?? null;
292
+
293
+ if (canApplyOrderedListCursorAfterBranch(descriptor, currentValue)) {
294
+ const afterMethod = useOr === true ? "orWhere" : "where";
295
+ query[afterMethod]((afterQuery) => {
296
+ applyOrderedListCursorAfterBranch(afterQuery, descriptor, currentValue);
297
+ });
298
+ addedBranch = true;
299
+ }
300
+
301
+ if (index >= orderBy.length - 1) {
302
+ return addedBranch;
303
+ }
304
+
305
+ const equalityMethod = useOr === true || addedBranch === true ? "orWhere" : "where";
306
+ query[equalityMethod]((equalQuery) => {
307
+ applyOrderedListCursorEquality(equalQuery, descriptor, currentValue);
308
+ equalQuery.where((nestedQuery) => {
309
+ appendOrderedListCursorBranches(nestedQuery, orderBy, cursorValues, index + 1);
310
+ });
311
+ });
312
+ return true;
313
+ }
314
+
315
+ function applyOrderedListCursorFilter(query, { orderBy = [], cursor = "" } = {}) {
316
+ const normalizedOrderBy = Array.isArray(orderBy) ? orderBy : [];
317
+ const cursorValues = decodeOrderedListCursor(cursor, normalizedOrderBy);
318
+ if (!cursorValues) {
319
+ return query;
320
+ }
321
+
322
+ return query.where((cursorQuery) => {
323
+ const appended = appendOrderedListCursorBranches(cursorQuery, normalizedOrderBy, cursorValues);
324
+ if (!appended) {
325
+ cursorQuery.whereRaw("1 = 0");
326
+ }
76
327
  });
77
328
  }
78
329
 
@@ -124,18 +375,27 @@ function createCrudRepositoryRuntime(resource = {}, { context = "crudRepository"
124
375
  const lookupRuntime = createCrudLookupRuntime(resource, {
125
376
  outputKeys: repositoryMapping.outputKeys
126
377
  });
378
+ const listRuntime = resolveListRuntimeConfig(list, repositoryMapping.listSearchColumns, {
379
+ idColumn: defaults.idColumn
380
+ });
127
381
  const { selectColumns } = buildRepositoryColumnMetadata({
128
382
  outputKeys: repositoryMapping.outputKeys,
129
383
  writeKeys: repositoryMapping.writeKeys,
130
384
  columnOverrides: repositoryMapping.columnOverrides
131
385
  });
386
+ const normalizedSelectColumns = Object.freeze(
387
+ [...new Set([
388
+ ...selectColumns,
389
+ ...listRuntime.orderBy.map(({ column }) => column)
390
+ ])]
391
+ );
132
392
 
133
393
  return Object.freeze({
134
394
  context,
135
395
  defaults,
136
- selectColumns,
396
+ selectColumns: normalizedSelectColumns,
137
397
  output,
138
- list: resolveListRuntimeConfig(list, repositoryMapping.listSearchColumns),
398
+ list: listRuntime,
139
399
  lookup: lookupRuntime,
140
400
  mapping: repositoryMapping
141
401
  });
@@ -372,7 +632,31 @@ async function applyCrudRepositoryAfterWriteHook(
372
632
  await hook(meta, hookContext);
373
633
  }
374
634
 
375
- function enforceCrudRepositoryListControls(dbQuery, { idColumn = "id", limit = DEFAULT_LIST_LIMIT + 1 } = {}) {
635
+ function applyOrderedListControls(dbQuery, orderBy = []) {
636
+ let nextQuery = dbQuery;
637
+ for (const descriptor of Array.isArray(orderBy) ? orderBy : []) {
638
+ if (typeof nextQuery.orderByRaw === "function") {
639
+ nextQuery = nextQuery.orderByRaw(
640
+ descriptor.nulls === LIST_ORDER_NULLS_FIRST
641
+ ? "?? is null desc"
642
+ : "?? is null asc",
643
+ [descriptor.column]
644
+ );
645
+ }
646
+ nextQuery = nextQuery.orderBy(descriptor.column, descriptor.direction);
647
+ }
648
+
649
+ return nextQuery;
650
+ }
651
+
652
+ function enforceCrudRepositoryListControls(
653
+ dbQuery,
654
+ {
655
+ idColumn = "id",
656
+ limit = DEFAULT_LIST_LIMIT + 1,
657
+ orderBy = []
658
+ } = {}
659
+ ) {
376
660
  let nextQuery = dbQuery;
377
661
  if (typeof nextQuery.clearOrder === "function") {
378
662
  nextQuery = nextQuery.clearOrder();
@@ -381,6 +665,12 @@ function enforceCrudRepositoryListControls(dbQuery, { idColumn = "id", limit = D
381
665
  nextQuery = nextQuery.clear("limit");
382
666
  }
383
667
 
668
+ const normalizedOrderBy = Array.isArray(orderBy) ? orderBy : [];
669
+ if (normalizedOrderBy.length > 0) {
670
+ return applyOrderedListControls(nextQuery, normalizedOrderBy)
671
+ .limit(limit);
672
+ }
673
+
384
674
  return nextQuery
385
675
  .orderBy(idColumn, "asc")
386
676
  .limit(limit);
@@ -421,17 +711,25 @@ async function crudRepositoryList(runtime, knex, query = {}, repositoryOptions =
421
711
  max: runtime.list.maxLimit
422
712
  });
423
713
  const hookContextBase = createCrudRepositoryHookContextBase(runtime, repositoryOptions, callOptions);
714
+ const usesOrderedListCursor = runtime.list.orderBy.length > 0;
424
715
  let dbQuery = client(tableName)
425
716
  .select(...runtime.selectColumns);
426
717
 
427
718
  dbQuery = applyCrudListQueryFilters(dbQuery, {
428
719
  idColumn,
429
720
  cursor: query?.cursor,
721
+ applyCursor: usesOrderedListCursor !== true,
430
722
  q: query?.q,
431
723
  searchColumns: runtime.list.searchColumns,
432
724
  parentFilters: query,
433
725
  parentFilterColumns: runtime.mapping.parentFilterColumns
434
726
  });
727
+ if (usesOrderedListCursor) {
728
+ dbQuery = applyOrderedListCursorFilter(dbQuery, {
729
+ orderBy: runtime.list.orderBy,
730
+ cursor: query?.cursor
731
+ });
732
+ }
435
733
 
436
734
  const listHookResult = await applyCrudRepositoryQueryHook(
437
735
  dbQuery,
@@ -450,7 +748,8 @@ async function crudRepositoryList(runtime, knex, query = {}, repositoryOptions =
450
748
  dbQuery = dbQuery.where(visible);
451
749
  dbQuery = enforceCrudRepositoryListControls(dbQuery, {
452
750
  idColumn,
453
- limit: normalizedLimit + 1
751
+ limit: normalizedLimit + 1,
752
+ orderBy: runtime.list.orderBy
454
753
  });
455
754
 
456
755
  const rows = await dbQuery;
@@ -511,7 +810,14 @@ async function crudRepositoryList(runtime, knex, query = {}, repositoryOptions =
511
810
  );
512
811
  }
513
812
 
514
- const nextCursor = hasMore && hydratedItems.length > 0 ? String(hydratedItems[hydratedItems.length - 1].id) : null;
813
+ const lastPageRow = pageRows[pageRows.length - 1] || null;
814
+ const nextCursor = hasMore && lastPageRow
815
+ ? (
816
+ usesOrderedListCursor
817
+ ? encodeOrderedListCursor(lastPageRow, runtime.list.orderBy)
818
+ : String(lastPageRow[idColumn])
819
+ )
820
+ : null;
515
821
  let output = {
516
822
  items: transformedItems,
517
823
  nextCursor
@@ -828,12 +1134,15 @@ async function crudRepositoryListByForeignIds(
828
1134
 
829
1135
  async function crudRepositoryCreate(runtime, knex, payload = {}, repositoryOptions = {}, callOptions = {}, hooks = null) {
830
1136
  const { client, tableName } = resolveCrudRepositoryCall(runtime, knex, repositoryOptions, callOptions);
831
- const methodHooks = normalizeCrudRepositoryHooks(hooks, ["modifyPayload", "modifyQuery", "afterWrite"], {
1137
+ const methodHooks = normalizeCrudRepositoryHooks(hooks, ["modifyPayload", "finalizeInsertPayload", "modifyQuery", "afterWrite"], {
832
1138
  context: "crudRepositoryCreate"
833
1139
  });
834
1140
  const modifyCreatePayload = resolveOptionalCrudRepositoryHook(methodHooks, "modifyPayload", {
835
1141
  context: "crudRepositoryCreate"
836
1142
  });
1143
+ const finalizeCreateInsertPayload = resolveOptionalCrudRepositoryHook(methodHooks, "finalizeInsertPayload", {
1144
+ context: "crudRepositoryCreate"
1145
+ });
837
1146
  const modifyCreateQuery = resolveOptionalCrudRepositoryHook(methodHooks, "modifyQuery", {
838
1147
  context: "crudRepositoryCreate"
839
1148
  });
@@ -855,7 +1164,7 @@ async function crudRepositoryCreate(runtime, knex, payload = {}, repositoryOptio
855
1164
  }
856
1165
  );
857
1166
 
858
- const insertPayload = buildWritePayload(sourcePayload, runtime.mapping.writeKeys, runtime.mapping.columnOverrides);
1167
+ let insertPayload = buildWritePayload(sourcePayload, runtime.mapping.writeKeys, runtime.mapping.columnOverrides);
859
1168
  const timestamp = toInsertDateTime();
860
1169
  if (runtime.defaults.createdAtColumn && !Object.hasOwn(insertPayload, runtime.defaults.createdAtColumn)) {
861
1170
  insertPayload[runtime.defaults.createdAtColumn] = timestamp;
@@ -863,6 +1172,21 @@ async function crudRepositoryCreate(runtime, knex, payload = {}, repositoryOptio
863
1172
  if (runtime.defaults.updatedAtColumn && !Object.hasOwn(insertPayload, runtime.defaults.updatedAtColumn)) {
864
1173
  insertPayload[runtime.defaults.updatedAtColumn] = timestamp;
865
1174
  }
1175
+ insertPayload = await applyCrudRepositoryPayloadHook(
1176
+ insertPayload,
1177
+ finalizeCreateInsertPayload,
1178
+ {
1179
+ payload: sourcePayload,
1180
+ insertPayload: {
1181
+ ...insertPayload
1182
+ },
1183
+ ...hookContextBase
1184
+ },
1185
+ {
1186
+ context: "crudRepositoryCreate",
1187
+ hookKey: "finalizeInsertPayload"
1188
+ }
1189
+ );
866
1190
 
867
1191
  let withOwners = applyVisibilityOwners(insertPayload, callOptions.visibilityContext);
868
1192
  let createQuery = client(tableName);