@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.
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.8-develop.6878.1.ab817e58ac";
1
+ const APP_VERSION = "0.6.8-develop.6881.1.8ea75912f2";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -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.6878.1.ab817e58ac';\nexport const appVersion = APP_VERSION;\n"],
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.6878.1.ab817e58ac",
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.6878.1.ab817e58ac",
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 } },
@@ -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 ?? 'id'
86
- const rawSortDir = queryParams.sortDir ?? queryParams.order ?? 'asc'
87
- const sortField = typeof rawSortField === 'string' && rawSortField.trim().length > 0 ? rawSortField.trim() : 'id'
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 { sortField: sortFieldRaw, sortDir: sortDirRaw } = resolveSortParams(queryParams as Record<string, unknown>)
1680
- const mappedSortField = (opts.list.sortFieldMap && opts.list.sortFieldMap[sortFieldRaw]) || sortFieldRaw
1681
- const sortField = typeof mappedSortField === 'string' ? normalizeSortFieldSelector(mappedSortField) : mappedSortField
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