@open-mercato/shared 0.6.8-develop.6878.1.ab817e58ac → 0.6.8-develop.6881.1.8ea75912f2
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/dist/lib/crud/factory.js +19 -7
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +101 -0
- package/src/lib/crud/factory.ts +48 -7
- package/src/lib/query/__tests__/engine.test.ts +86 -0
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.6881.1.8ea75912f2';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.6881.1.8ea75912f2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"@mikro-orm/core": "^7.1.8",
|
|
106
106
|
"@mikro-orm/decorators": "^7.1.8",
|
|
107
107
|
"@mikro-orm/postgresql": "^7.1.8",
|
|
108
|
-
"@open-mercato/cache": "0.6.8-develop.
|
|
108
|
+
"@open-mercato/cache": "0.6.8-develop.6881.1.8ea75912f2",
|
|
109
109
|
"@types/sanitize-html": "^2.16.1",
|
|
110
110
|
"dotenv": "^17.4.2",
|
|
111
111
|
"pino": "^10.3.1",
|
|
@@ -373,6 +373,107 @@ describe('CRUD Factory', () => {
|
|
|
373
373
|
])
|
|
374
374
|
})
|
|
375
375
|
|
|
376
|
+
// Routes that delegate the fallback order to `list.defaultSort` leave `sortField`
|
|
377
|
+
// optional; a zod `.default()` would make every request look explicitly sorted.
|
|
378
|
+
const sortableQuerySchema = z.object({
|
|
379
|
+
page: z.coerce.number().default(1),
|
|
380
|
+
pageSize: z.coerce.number().default(50),
|
|
381
|
+
sortField: z.string().optional(),
|
|
382
|
+
sortDir: z.enum(['asc', 'desc']).optional(),
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
const makeSortedRoute = (list?: Partial<Parameters<typeof makeCrudRoute>[0]['list']>) => makeCrudRoute({
|
|
386
|
+
metadata: { GET: { requireAuth: true } },
|
|
387
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
388
|
+
indexer: { entityType: 'example.todo' },
|
|
389
|
+
list: {
|
|
390
|
+
schema: sortableQuerySchema,
|
|
391
|
+
entityId: 'example.todo',
|
|
392
|
+
fields: ['id', 'title'],
|
|
393
|
+
sortFieldMap: { id: 'id', title: 'title', lineNumber: 'line_number' },
|
|
394
|
+
buildFilters: () => ({} as any),
|
|
395
|
+
disableListCache: true,
|
|
396
|
+
...list,
|
|
397
|
+
} as any,
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('GET falls back to sorting by id when no default sort is configured', async () => {
|
|
401
|
+
await makeSortedRoute().GET(new Request('http://x/api/example/todos?page=1&pageSize=10'))
|
|
402
|
+
|
|
403
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
404
|
+
expect(queryArgs?.sort).toEqual([{ field: 'id', dir: 'asc' }])
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('GET applies list.defaultSort through sortFieldMap when the request omits a sort', async () => {
|
|
408
|
+
const sortedRoute = makeSortedRoute({
|
|
409
|
+
defaultSort: { field: 'lineNumber', dir: 'asc' },
|
|
410
|
+
tiebreakSortField: 'id',
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10'))
|
|
414
|
+
|
|
415
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
416
|
+
expect(queryArgs?.sort).toEqual([
|
|
417
|
+
{ field: 'line_number', dir: 'asc' },
|
|
418
|
+
{ field: 'id', dir: 'asc' },
|
|
419
|
+
])
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('GET honours an explicit sort over list.defaultSort and keeps the tiebreak', async () => {
|
|
423
|
+
const sortedRoute = makeSortedRoute({
|
|
424
|
+
defaultSort: { field: 'lineNumber', dir: 'asc' },
|
|
425
|
+
tiebreakSortField: 'id',
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=title&sortDir=desc'))
|
|
429
|
+
|
|
430
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
431
|
+
expect(queryArgs?.sort).toEqual([
|
|
432
|
+
{ field: 'title', dir: 'desc' },
|
|
433
|
+
{ field: 'id', dir: 'asc' },
|
|
434
|
+
])
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('GET keeps an explicit sort ascending by default even when list.defaultSort is descending', async () => {
|
|
438
|
+
const sortedRoute = makeSortedRoute({ defaultSort: { field: 'title', dir: 'desc' } })
|
|
439
|
+
|
|
440
|
+
await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id'))
|
|
441
|
+
|
|
442
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
443
|
+
expect(queryArgs?.sort).toEqual([{ field: 'id', dir: 'asc' }])
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('GET treats a blank sortField as absent and falls back to list.defaultSort', async () => {
|
|
447
|
+
const sortedRoute = makeSortedRoute({
|
|
448
|
+
defaultSort: { field: 'lineNumber', dir: 'asc' },
|
|
449
|
+
tiebreakSortField: 'id',
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField='))
|
|
453
|
+
|
|
454
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
455
|
+
expect(queryArgs?.sort).toEqual([
|
|
456
|
+
{ field: 'line_number', dir: 'asc' },
|
|
457
|
+
{ field: 'id', dir: 'asc' },
|
|
458
|
+
])
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('GET keeps falling back to id for a blank sortField when no default is configured', async () => {
|
|
462
|
+
await makeSortedRoute().GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=&sortDir=desc'))
|
|
463
|
+
|
|
464
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
465
|
+
expect(queryArgs?.sort).toEqual([{ field: 'id', dir: 'desc' }])
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('GET does not duplicate the tiebreak when it matches the primary sort', async () => {
|
|
469
|
+
const sortedRoute = makeSortedRoute({ tiebreakSortField: 'id' })
|
|
470
|
+
|
|
471
|
+
await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=desc'))
|
|
472
|
+
|
|
473
|
+
const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
|
|
474
|
+
expect(queryArgs?.sort).toEqual([{ field: 'id', dir: 'desc' }])
|
|
475
|
+
})
|
|
476
|
+
|
|
376
477
|
it('GET intersects ids with existing buildFilters id constraint', async () => {
|
|
377
478
|
const routeWithIdFilter = makeCrudRoute({
|
|
378
479
|
metadata: { GET: { requireAuth: true } },
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -81,10 +81,13 @@ type RbacServiceLike = {
|
|
|
81
81
|
|
|
82
82
|
const logger = createLogger('shared').child({ component: 'crud' })
|
|
83
83
|
|
|
84
|
-
function resolveSortParams(queryParams: Record<string, unknown
|
|
85
|
-
const rawSortField = queryParams.sortField ?? queryParams.sort
|
|
86
|
-
const
|
|
87
|
-
|
|
84
|
+
function resolveSortParams(queryParams: Record<string, unknown>, defaultSort?: CrudDefaultSort) {
|
|
85
|
+
const rawSortField = queryParams.sortField ?? queryParams.sort
|
|
86
|
+
const requestedSortField =
|
|
87
|
+
typeof rawSortField === 'string' && rawSortField.trim().length > 0 ? rawSortField.trim() : null
|
|
88
|
+
const sortField = requestedSortField ?? defaultSort?.field ?? 'id'
|
|
89
|
+
const rawSortDir =
|
|
90
|
+
queryParams.sortDir ?? queryParams.order ?? (requestedSortField ? 'asc' : defaultSort?.dir ?? 'asc')
|
|
88
91
|
const normalizedDir = typeof rawSortDir === 'string' ? rawSortDir.trim().toLowerCase() : 'asc'
|
|
89
92
|
const sortDir = normalizedDir === 'desc' ? SortDir.Desc : SortDir.Asc
|
|
90
93
|
return { sortField, sortDir }
|
|
@@ -192,6 +195,8 @@ export type CrudListCustomFieldDecorator = {
|
|
|
192
195
|
stripPrefixedKeys?: boolean
|
|
193
196
|
}
|
|
194
197
|
|
|
198
|
+
export type CrudDefaultSort = { field: string; dir?: 'asc' | 'desc' }
|
|
199
|
+
|
|
195
200
|
export type ListConfig<TList> = {
|
|
196
201
|
schema: z.ZodType<TList>
|
|
197
202
|
// Optional: use the QueryEngine when entityId + fields are provided.
|
|
@@ -202,6 +207,31 @@ export type ListConfig<TList> = {
|
|
|
202
207
|
entityId?: any
|
|
203
208
|
fields?: any[] | ((query: TList, ctx: CrudCtx) => any[])
|
|
204
209
|
sortFieldMap?: Record<string, any>
|
|
210
|
+
/**
|
|
211
|
+
* Sort used when the request carries no `sortField` / `sort` param. The field is
|
|
212
|
+
* resolved through `sortFieldMap`, so a name the map defines is translated to its
|
|
213
|
+
* column and an unmapped name is used as the column directly. `dir` only applies
|
|
214
|
+
* together with the default field — an explicit `sortField` without `sortDir`
|
|
215
|
+
* still defaults to ascending. Defaults to `{ field: 'id', dir: 'asc' }`, which is
|
|
216
|
+
* only a meaningful order for sequential ids, never for random UUIDs.
|
|
217
|
+
*
|
|
218
|
+
* Applies to the Query Engine list path only (the route must set both `entityId`
|
|
219
|
+
* and `fields`). The plain-ORM fallback list issues an unordered `find` and
|
|
220
|
+
* already ignores `sortField` today, so it ignores this too.
|
|
221
|
+
*
|
|
222
|
+
* The list schema must keep `sortField` optional for this to take effect — a zod
|
|
223
|
+
* `.default()` on `sortField` reaches the sort resolver as an explicit request and
|
|
224
|
+
* pins the order itself.
|
|
225
|
+
*/
|
|
226
|
+
defaultSort?: CrudDefaultSort
|
|
227
|
+
/**
|
|
228
|
+
* Appended as a secondary ascending sort whenever it differs from the resolved
|
|
229
|
+
* primary sort, so rows sharing a primary value keep a stable order across pages
|
|
230
|
+
* and re-fetches instead of falling back to the database's arbitrary row order.
|
|
231
|
+
* Applies to explicit sorts too, and is resolved through `sortFieldMap` and gated
|
|
232
|
+
* on the Query Engine path exactly like `defaultSort`.
|
|
233
|
+
*/
|
|
234
|
+
tiebreakSortField?: string
|
|
205
235
|
buildFilters?: (query: TList, ctx: CrudCtx) => Where<any> | Promise<Where<any>>
|
|
206
236
|
transformItem?: (item: any) => any
|
|
207
237
|
allowCsv?: boolean
|
|
@@ -1676,10 +1706,21 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1676
1706
|
profiler.mark('query_engine_prepare')
|
|
1677
1707
|
const qe = (ctx.container.resolve('queryEngine') as QueryEngine)
|
|
1678
1708
|
profiler.mark('query_engine_resolved')
|
|
1679
|
-
const
|
|
1680
|
-
const
|
|
1681
|
-
|
|
1709
|
+
const sortFieldMap = opts.list.sortFieldMap
|
|
1710
|
+
const resolveSortSelector = (field: string) => {
|
|
1711
|
+
const mapped = (sortFieldMap && sortFieldMap[field]) || field
|
|
1712
|
+
return typeof mapped === 'string' ? normalizeSortFieldSelector(mapped) : mapped
|
|
1713
|
+
}
|
|
1714
|
+
const { sortField: sortFieldRaw, sortDir: sortDirRaw } = resolveSortParams(
|
|
1715
|
+
queryParams as Record<string, unknown>,
|
|
1716
|
+
opts.list.defaultSort,
|
|
1717
|
+
)
|
|
1718
|
+
const sortField = resolveSortSelector(sortFieldRaw)
|
|
1682
1719
|
const sort: Sort[] = [{ field: sortField as any, dir: sortDirRaw } as any]
|
|
1720
|
+
if (opts.list.tiebreakSortField) {
|
|
1721
|
+
const tiebreakField = resolveSortSelector(opts.list.tiebreakSortField)
|
|
1722
|
+
if (tiebreakField !== sortField) sort.push({ field: tiebreakField as any, dir: SortDir.Asc } as any)
|
|
1723
|
+
}
|
|
1683
1724
|
const page: Page = exportRequested
|
|
1684
1725
|
? { page: 1, pageSize: exportPageSize }
|
|
1685
1726
|
: { page: requestedPage, pageSize: requestedPageSize }
|
|
@@ -943,6 +943,92 @@ describe('BasicQueryEngine (Kysely)', () => {
|
|
|
943
943
|
expect(baseCall._ops.offsets).toBe(10)
|
|
944
944
|
})
|
|
945
945
|
|
|
946
|
+
// A tiebreak sort is only worth configuring if the engine actually emits it.
|
|
947
|
+
// `list.tiebreakSortField` (used by the sales line routes to keep lines with an
|
|
948
|
+
// equal `line_number` in a repeatable order) is the first caller to pass more
|
|
949
|
+
// than one sort element, so pin that every element reaches ORDER BY in order —
|
|
950
|
+
// dropping sort[1] would silently restore the non-determinism it exists to fix.
|
|
951
|
+
test('emits every sort element as an ORDER BY column, in order', async () => {
|
|
952
|
+
const fakeDb = createFakeKysely({
|
|
953
|
+
sales_order_lines: [
|
|
954
|
+
{ id: 'b', tenant_id: 't1', organization_id: 'org1', line_number: 0 },
|
|
955
|
+
{ id: 'a', tenant_id: 't1', organization_id: 'org1', line_number: 0 },
|
|
956
|
+
],
|
|
957
|
+
'information_schema.columns': [
|
|
958
|
+
{ table_name: 'sales_order_lines', column_name: 'id' },
|
|
959
|
+
{ table_name: 'sales_order_lines', column_name: 'tenant_id' },
|
|
960
|
+
{ table_name: 'sales_order_lines', column_name: 'organization_id' },
|
|
961
|
+
{ table_name: 'sales_order_lines', column_name: 'deleted_at' },
|
|
962
|
+
{ table_name: 'sales_order_lines', column_name: 'line_number' },
|
|
963
|
+
],
|
|
964
|
+
})
|
|
965
|
+
const engine = new BasicQueryEngine(
|
|
966
|
+
{} as any,
|
|
967
|
+
() => fakeDb as any,
|
|
968
|
+
() => ({
|
|
969
|
+
isEnabled: () => true,
|
|
970
|
+
getEncryptedFieldNames: async () => [],
|
|
971
|
+
}),
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
await engine.query('sales:sales_order_line', {
|
|
975
|
+
tenantId: 't1',
|
|
976
|
+
organizationId: 'org1',
|
|
977
|
+
fields: ['id', 'line_number'],
|
|
978
|
+
sort: [
|
|
979
|
+
{ field: 'line_number', dir: SortDir.Asc },
|
|
980
|
+
{ field: 'id', dir: SortDir.Asc },
|
|
981
|
+
],
|
|
982
|
+
page: { page: 1, pageSize: 10 },
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
const baseCall = fakeDb._calls.find((call: any) => call._ops.table === 'sales_order_lines')
|
|
986
|
+
expect(baseCall._ops.orderBys).toEqual([
|
|
987
|
+
['sales_order_lines.line_number', 'asc'],
|
|
988
|
+
['sales_order_lines.id', 'asc'],
|
|
989
|
+
])
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
test('keeps each sort element on its own direction', async () => {
|
|
993
|
+
const fakeDb = createFakeKysely({
|
|
994
|
+
sales_order_lines: [
|
|
995
|
+
{ id: 'a', tenant_id: 't1', organization_id: 'org1', line_number: 1 },
|
|
996
|
+
],
|
|
997
|
+
'information_schema.columns': [
|
|
998
|
+
{ table_name: 'sales_order_lines', column_name: 'id' },
|
|
999
|
+
{ table_name: 'sales_order_lines', column_name: 'tenant_id' },
|
|
1000
|
+
{ table_name: 'sales_order_lines', column_name: 'organization_id' },
|
|
1001
|
+
{ table_name: 'sales_order_lines', column_name: 'deleted_at' },
|
|
1002
|
+
{ table_name: 'sales_order_lines', column_name: 'line_number' },
|
|
1003
|
+
],
|
|
1004
|
+
})
|
|
1005
|
+
const engine = new BasicQueryEngine(
|
|
1006
|
+
{} as any,
|
|
1007
|
+
() => fakeDb as any,
|
|
1008
|
+
() => ({
|
|
1009
|
+
isEnabled: () => true,
|
|
1010
|
+
getEncryptedFieldNames: async () => [],
|
|
1011
|
+
}),
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
await engine.query('sales:sales_order_line', {
|
|
1015
|
+
tenantId: 't1',
|
|
1016
|
+
organizationId: 'org1',
|
|
1017
|
+
fields: ['id', 'line_number'],
|
|
1018
|
+
sort: [
|
|
1019
|
+
{ field: 'line_number', dir: SortDir.Desc },
|
|
1020
|
+
{ field: 'id', dir: SortDir.Asc },
|
|
1021
|
+
],
|
|
1022
|
+
page: { page: 1, pageSize: 10 },
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
const baseCall = fakeDb._calls.find((call: any) => call._ops.table === 'sales_order_lines')
|
|
1026
|
+
expect(baseCall._ops.orderBys).toEqual([
|
|
1027
|
+
['sales_order_lines.line_number', 'desc'],
|
|
1028
|
+
['sales_order_lines.id', 'asc'],
|
|
1029
|
+
])
|
|
1030
|
+
})
|
|
1031
|
+
|
|
946
1032
|
describe('search_tokens coverage probe (#4723 parity)', () => {
|
|
947
1033
|
type ProbeDbLog = { _calls: Array<{ _ops: { table: string } }> }
|
|
948
1034
|
|