@open-mercato/shared 0.6.8-develop.6930.1.1e5976efc3 → 0.6.8-develop.6940.1.177ea30c6e

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.
@@ -236,9 +236,13 @@ export type ListConfig<TList> = {
236
236
  buildFilters?: (query: TList, ctx: CrudCtx) => Where<any> | Promise<Where<any>>
237
237
  transformItem?: (item: any) => any
238
238
  allowCsv?: boolean
239
+ // The function forms mirror `fields` above: a route whose export columns depend
240
+ // on per-request state (for example custom-field definitions discovered in
241
+ // `beforeList`) MUST resolve them from `ctx` rather than from module-level
242
+ // mutable state, which would bleed one tenant's columns into another's export.
239
243
  csv?: {
240
- headers: string[]
241
- row: (item: any) => (string | number | boolean | null | undefined)[]
244
+ headers: string[] | ((query: TList, ctx: CrudCtx) => string[])
245
+ row: (item: any, ctx: CrudCtx) => (string | number | boolean | null | undefined)[]
242
246
  filename?: string
243
247
  }
244
248
  export?: CrudExportOptions
@@ -347,14 +351,20 @@ function buildExportFromColumns(items: any[], columnsConfig: CrudExportColumnCon
347
351
  }
348
352
  }
349
353
 
350
- function buildExportFromCsv(items: any[], csv: NonNullable<ListConfig<any>['csv']>): PreparedExport {
354
+ function buildExportFromCsv(
355
+ items: any[],
356
+ csv: NonNullable<ListConfig<any>['csv']>,
357
+ query: unknown,
358
+ ctx: CrudCtx,
359
+ ): PreparedExport {
351
360
  const used = new Set<string>()
352
- const columns = csv.headers.map((header, idx) => ({
361
+ const resolvedHeaders = typeof csv.headers === 'function' ? csv.headers(query as any, ctx) : csv.headers
362
+ const columns = resolvedHeaders.map((header, idx) => ({
353
363
  field: sanitizeFieldName(header || `column_${idx + 1}`, used, idx),
354
364
  header: header || `Column ${idx + 1}`,
355
365
  }))
356
366
  const rows = items.map((item) => {
357
- const values = csv.row(item) || []
367
+ const values = csv.row(item, ctx) || []
358
368
  const row: Record<string, unknown> = {}
359
369
  columns.forEach((column, idx) => {
360
370
  row[column.field] = values[idx]
@@ -377,12 +387,12 @@ function buildDefaultExport(items: any[]): PreparedExport {
377
387
  }
378
388
  }
379
389
 
380
- function prepareExportData(items: any[], list: ListConfig<any>): PreparedExport {
390
+ function prepareExportData(items: any[], list: ListConfig<any>, query: unknown, ctx: CrudCtx): PreparedExport {
381
391
  if (list.export?.columns && list.export.columns.length > 0) {
382
392
  return buildExportFromColumns(items, list.export.columns)
383
393
  }
384
394
  if (list.csv) {
385
- return buildExportFromCsv(items, list.csv)
395
+ return buildExportFromCsv(items, list.csv, query, ctx)
386
396
  }
387
397
  const prepared = buildDefaultExport(items)
388
398
  return {
@@ -1890,7 +1900,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1890
1900
  }
1891
1901
  const prepared = exportFullRequested
1892
1902
  ? { columns: ensureColumns(exportItems), rows: exportItems }
1893
- : prepareExportData(exportItems, opts.list)
1903
+ : prepareExportData(exportItems, opts.list, validated as any, ctx)
1894
1904
  const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
1895
1905
  const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
1896
1906
  const serialized = serializeExport(prepared, requestedExport)
@@ -2092,7 +2102,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2092
2102
  const exportItems = exportFullRequested ? list.map(normalizeFullRecordForExport) : list
2093
2103
  const prepared = exportFullRequested
2094
2104
  ? { columns: ensureColumns(exportItems), rows: exportItems }
2095
- : prepareExportData(exportItems, opts.list)
2105
+ : prepareExportData(exportItems, opts.list, validated as any, ctx)
2096
2106
  const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
2097
2107
  const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
2098
2108
  const serialized = serializeExport(prepared, requestedExport)
@@ -0,0 +1,150 @@
1
+ import { z } from 'zod'
2
+ import { buildOpenApiDocument } from '../generator'
3
+ import type { Module, ModuleApiRouteFile } from '../../../modules/registry'
4
+ import type { OpenApiMethodDoc } from '../types'
5
+
6
+ type ResponseEntry = {
7
+ description?: string
8
+ content?: Record<string, { schema?: { properties?: Record<string, unknown> } }>
9
+ 'x-autoGenerated'?: boolean
10
+ }
11
+
12
+ type Operation = { responses: Record<string, ResponseEntry> }
13
+
14
+ const errorSchema = z.object({ error: z.string(), code: z.string() })
15
+ const routePath = '/example/records'
16
+ const guardedMetadata = { GET: { requireAuth: true, requireFeatures: ['example.records.view'] } }
17
+
18
+ function makeModule(methodDoc: OpenApiMethodDoc, metadata: ModuleApiRouteFile['metadata']): Module {
19
+ const api: ModuleApiRouteFile = {
20
+ path: routePath,
21
+ metadata,
22
+ handlers: { GET: async () => new Response(null) },
23
+ docs: { tag: 'Example', methods: { GET: methodDoc } },
24
+ }
25
+ return { id: 'example', apis: [api] }
26
+ }
27
+
28
+ function getOperationWithMetadata(
29
+ methodDoc: OpenApiMethodDoc,
30
+ metadata: ModuleApiRouteFile['metadata']
31
+ ): Operation {
32
+ const doc = buildOpenApiDocument([makeModule(methodDoc, metadata)])
33
+ return doc.paths[routePath]?.get as Operation
34
+ }
35
+
36
+ function getOperation(methodDoc: OpenApiMethodDoc): Operation {
37
+ return getOperationWithMetadata(methodDoc, guardedMetadata)
38
+ }
39
+
40
+ function jsonSchemaProperties(entry: ResponseEntry): Record<string, unknown> | undefined {
41
+ return entry.content?.['application/json']?.schema?.properties
42
+ }
43
+
44
+ describe('buildResponses precedence between declared and inferred responses', () => {
45
+ it('keeps a 403 declared through responses instead of the requireFeatures default', () => {
46
+ const operation = getOperation({
47
+ responses: [
48
+ { status: 200, description: 'Records', schema: z.object({ items: z.array(z.string()) }) },
49
+ { status: 403, description: 'Record is outside the active scope', schema: errorSchema },
50
+ ],
51
+ })
52
+
53
+ expect(operation.responses['403'].description).toBe('Record is outside the active scope')
54
+ expect(operation.responses['403']['x-autoGenerated']).toBeUndefined()
55
+ expect(jsonSchemaProperties(operation.responses['403'])?.code).toBeDefined()
56
+ })
57
+
58
+ it('keeps a 403 declared through errors instead of the requireFeatures default', () => {
59
+ const operation = getOperation({
60
+ responses: [{ status: 200, description: 'Records' }],
61
+ errors: [{ status: 403, description: 'Record is outside the active scope', schema: errorSchema }],
62
+ })
63
+
64
+ expect(operation.responses['403'].description).toBe('Record is outside the active scope')
65
+ expect(operation.responses['403']['x-autoGenerated']).toBeUndefined()
66
+ expect(jsonSchemaProperties(operation.responses['403'])?.code).toBeDefined()
67
+ })
68
+
69
+ it('keeps a 401 declared through errors instead of the requireAuth default', () => {
70
+ const operation = getOperation({
71
+ responses: [{ status: 200, description: 'Records' }],
72
+ errors: [{ status: 401, description: 'Session token expired', schema: errorSchema }],
73
+ })
74
+
75
+ expect(operation.responses['401'].description).toBe('Session token expired')
76
+ expect(operation.responses['401']['x-autoGenerated']).toBeUndefined()
77
+ })
78
+
79
+ it('keeps a 401 declared through responses instead of the requireAuth default', () => {
80
+ const operation = getOperation({
81
+ responses: [
82
+ { status: 200, description: 'Records' },
83
+ { status: 401, description: 'Session token expired', schema: errorSchema },
84
+ ],
85
+ })
86
+
87
+ expect(operation.responses['401'].description).toBe('Session token expired')
88
+ expect(operation.responses['401']['x-autoGenerated']).toBeUndefined()
89
+ })
90
+
91
+ it('emits both inferred entries when the route documents neither status', () => {
92
+ const operation = getOperation({
93
+ responses: [{ status: 200, description: 'Records' }],
94
+ errors: [{ status: 404, description: 'Not found', schema: errorSchema }],
95
+ })
96
+
97
+ expect(operation.responses['401'].description).toBe('Unauthorized')
98
+ expect(operation.responses['401']['x-autoGenerated']).toBe(true)
99
+ expect(operation.responses['403'].description).toBe('Forbidden – missing required features')
100
+ expect(operation.responses['403']['x-autoGenerated']).toBe(true)
101
+ expect(operation.responses['404'].description).toBe('Not found')
102
+ expect(operation.responses['404']['x-autoGenerated']).toBeUndefined()
103
+ })
104
+
105
+ it('mixes a declared 403 with the inferred 401 on the same operation', () => {
106
+ const operation = getOperation({
107
+ responses: [{ status: 200, description: 'Records' }],
108
+ errors: [{ status: 403, description: 'Record is outside the active scope', schema: errorSchema }],
109
+ })
110
+
111
+ expect(operation.responses['403']['x-autoGenerated']).toBeUndefined()
112
+ expect(operation.responses['401']['x-autoGenerated']).toBe(true)
113
+ })
114
+
115
+ it('still synthesises the success response when the route declares only errors', () => {
116
+ const operation = getOperation({
117
+ errors: [{ status: 403, description: 'Record is outside the active scope', schema: errorSchema }],
118
+ })
119
+
120
+ expect(operation.responses['200'].description).toBe('Success response')
121
+ expect(operation.responses['403'].description).toBe('Record is outside the active scope')
122
+ })
123
+
124
+ it('infers nothing when the route declares no auth or feature metadata', () => {
125
+ const operation = getOperationWithMetadata({ responses: [{ status: 200, description: 'Records' }] }, undefined)
126
+
127
+ expect(Object.keys(operation.responses)).toEqual(['200'])
128
+ })
129
+
130
+ it('infers only the 401 when the route requires auth without features', () => {
131
+ const operation = getOperationWithMetadata({ responses: [{ status: 200, description: 'Records' }] }, {
132
+ GET: { requireAuth: true },
133
+ })
134
+
135
+ expect(operation.responses['401']['x-autoGenerated']).toBe(true)
136
+ expect(operation.responses['403']).toBeUndefined()
137
+ })
138
+
139
+ it('pins the documented last-wins behaviour when responses and errors collide on one status', () => {
140
+ const operation = getOperation({
141
+ responses: [
142
+ { status: 200, description: 'Records' },
143
+ { status: 409, description: 'Declared through responses', schema: errorSchema },
144
+ ],
145
+ errors: [{ status: 409, description: 'Declared through errors', schema: errorSchema }],
146
+ })
147
+
148
+ expect(operation.responses['409'].description).toBe('Declared through errors')
149
+ })
150
+ })
@@ -785,8 +785,9 @@ function buildResponses(
785
785
  const entries: Record<string, unknown> = {}
786
786
  const list = [...(responses ?? [])]
787
787
  const errorList = [...(errors ?? [])]
788
+ const inferredList: OpenApiResponseDoc[] = []
788
789
  if (metadata?.requireAuth) {
789
- errorList.push({
790
+ inferredList.push({
790
791
  status: 401,
791
792
  description: 'Unauthorized',
792
793
  schema: z.object({ error: z.string() }),
@@ -794,7 +795,7 @@ function buildResponses(
794
795
  })
795
796
  }
796
797
  if (Array.isArray(metadata?.requireFeatures) && metadata.requireFeatures.length) {
797
- errorList.push({
798
+ inferredList.push({
798
799
  status: 403,
799
800
  description: 'Forbidden – missing required features',
800
801
  schema: z.object({ error: z.string() }),
@@ -808,8 +809,9 @@ function buildResponses(
808
809
  description: fallbackStatus === 204 ? 'Success' : 'Success response',
809
810
  })
810
811
  }
811
- for (const res of [...list, ...errorList]) {
812
+ const setEntry = (res: OpenApiResponseDoc, options?: { skipIfPresent?: boolean }) => {
812
813
  const status = String(res.status || 200)
814
+ if (options?.skipIfPresent && Object.prototype.hasOwnProperty.call(entries, status)) return
813
815
  const mediaType = res.mediaType ?? 'application/json'
814
816
  const schema = res.schema ? zodToJsonSchema(res.schema) : undefined
815
817
  const example = res.schema ? res.example ?? generateExample(res.schema) : res.example
@@ -829,6 +831,8 @@ function buildResponses(
829
831
  ...(res.xAutoGenerated ? { 'x-autoGenerated': true } : {}),
830
832
  }
831
833
  }
834
+ for (const res of [...list, ...errorList]) setEntry(res)
835
+ for (const res of inferredList) setEntry(res, { skipIfPresent: true })
832
836
  return entries
833
837
  }
834
838
 
@@ -12,6 +12,17 @@ beforeEach(() => {
12
12
  // Mock modules with one entity extension
13
13
  const mockModules = [
14
14
  { id: 'auth', entityExtensions: [ { base: 'auth:user', extension: 'my_module:user_profile', join: { baseKey: 'id', extensionKey: 'user_id' } } ] },
15
+ {
16
+ id: 'example',
17
+ entityExtensions: [
18
+ // Declares `table` explicitly; the derived plural now agrees with it.
19
+ { base: 'customers:customer_entity', extension: 'example:example_customer_priority', join: { baseKey: 'id', extensionKey: 'customer_id' }, table: 'example_customer_priorities' },
20
+ // No `table`: exercises the derived-plural fallback.
21
+ { base: 'auth:role', extension: 'example:example_role_policy', join: { baseKey: 'id', extensionKey: 'role_id' } },
22
+ // `table` is not a bare identifier, so the engine must refuse it.
23
+ { base: 'auth:session', extension: 'example:example_session_note', join: { baseKey: 'id', extensionKey: 'session_id' }, table: 'notes"; drop table users --' },
24
+ ],
25
+ },
15
26
  ]
16
27
 
17
28
  // Register modules for the registration-based pattern
@@ -1072,3 +1083,54 @@ describe('BasicQueryEngine (Kysely)', () => {
1072
1083
  })
1073
1084
  })
1074
1085
  })
1086
+
1087
+ describe('BasicQueryEngine entity-extension joins', () => {
1088
+ function extensionJoins(fakeDb: any, baseTable: string): any[] {
1089
+ const baseCall = fakeDb._calls.find((builder: any) => builder._ops.table === baseTable)
1090
+ expect(baseCall).toBeTruthy()
1091
+ return baseCall._ops.joins.filter((entry: any) => Object.keys(entry.aliasObj)[0].startsWith('ext_'))
1092
+ }
1093
+
1094
+ async function joinFor(entity: string, baseTable: string): Promise<any> {
1095
+ const fakeDb = createFakeKysely()
1096
+ const engine = new BasicQueryEngine({} as any, () => fakeDb as any)
1097
+ await engine.query(entity, {
1098
+ tenantId: 't1',
1099
+ organizationId: 'org1',
1100
+ fields: ['id'],
1101
+ includeExtensions: true,
1102
+ })
1103
+ const joins = extensionJoins(fakeDb, baseTable)
1104
+ expect(joins).toHaveLength(1)
1105
+ return joins[0]
1106
+ }
1107
+
1108
+ test('prefers a declared table over the derived plural', async () => {
1109
+ const join = await joinFor('customers:customer_entity', 'customer_entities')
1110
+ expect(join.aliasObj).toEqual({ ext_example_customer_priority: 'example_customer_priorities' })
1111
+ expect(join.conditions).toEqual([
1112
+ {
1113
+ method: 'on',
1114
+ args: ['ext_example_customer_priority.customer_id', '=', 'customer_entities.id'],
1115
+ },
1116
+ ])
1117
+ })
1118
+
1119
+ test('falls back to the derived plural when no table is declared', async () => {
1120
+ // `policy` ends in `y`, so the correct plural is `policies`. This previously asserted
1121
+ // `policys`, pinning a separate inline `+s` pluralizer that the extension-join path used
1122
+ // instead of the file's own `pluralizeBaseName` — the very bug that made
1123
+ // `example_customer_priority` derive `example_customer_prioritys` and forced the
1124
+ // `table` override into existence.
1125
+ const join = await joinFor('auth:role', 'roles')
1126
+ expect(join.aliasObj).toEqual({ ext_example_role_policy: 'example_role_policies' })
1127
+ expect(join.conditions).toEqual([
1128
+ { method: 'on', args: ['ext_example_role_policy.role_id', '=', 'roles.id'] },
1129
+ ])
1130
+ })
1131
+
1132
+ test('ignores a declared table that is not a bare identifier', async () => {
1133
+ const join = await joinFor('auth:session', 'sessions')
1134
+ expect(join.aliasObj).toEqual({ ext_example_session_note: 'example_session_notes' })
1135
+ })
1136
+ })
@@ -72,6 +72,16 @@ const pluralizeBaseName = (name: string): string => {
72
72
  return `${name}s`
73
73
  }
74
74
 
75
+ /**
76
+ * Accepts a module-declared `EntityExtension.table` only when it is a bare SQL
77
+ * identifier. The value is interpolated into a join clause, so anything else is
78
+ * ignored in favour of the derived table name.
79
+ */
80
+ const PLAIN_TABLE_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
81
+
82
+ const isPlainTableIdentifier = (value: unknown): value is string =>
83
+ typeof value === 'string' && PLAIN_TABLE_IDENTIFIER_PATTERN.test(value)
84
+
75
85
  const toPascalCase = (value: string): string => {
76
86
  return value
77
87
  .split(/[_\s]+/)
@@ -955,7 +965,15 @@ export class BasicQueryEngine implements QueryEngine {
955
965
  : exts
956
966
  for (const e of chosen) {
957
967
  const [, extName] = (e.extension as string).split(':')
958
- const extTable = extName.endsWith('s') ? extName : `${extName}s`
968
+ // Uses the SAME derivation as every other table-name fallback in this file
969
+ // (`pluralizeBaseName`, also called at the resolveEntityTableName sites above)
970
+ // rather than a separate inline one. The inline version handled only `+s`, so
971
+ // `example_customer_priority` derived `example_customer_prioritys` against the
972
+ // real `example_customer_priorities`. Behaviour is unchanged for every name that
973
+ // does not end in `y`; `table` below remains the escape hatch for irregular
974
+ // plurals no guesser can win (`person` → `people`).
975
+ const derivedTable = pluralizeBaseName(extName)
976
+ const extTable = isPlainTableIdentifier(e.table) ? e.table : derivedTable
959
977
  const alias = `ext_${sanitize(extName)}`
960
978
  q = q.leftJoin(`${extTable} as ${alias}` as any, (jb: any) =>
961
979
  jb.onRef(`${alias}.${e.join.extensionKey}`, '=', `${table}.${e.join.baseKey}`)
@@ -1,5 +1,5 @@
1
1
  import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'
2
- import { REDIS_WIRE_PROTOCOL } from '../redis/connection'
2
+ import { parseRedisUrl } from '../redis/connection'
3
3
  import type { RateLimitConfig, RateLimitResult, RateLimitGlobalConfig } from './types'
4
4
 
5
5
  /** Narrow interface for the ioredis client — only the methods we actually use. */
@@ -22,10 +22,10 @@ export class RateLimiterService {
22
22
  async initialize(): Promise<void> {
23
23
  if (this.globalConfig.strategy === 'redis' && this.globalConfig.redisUrl) {
24
24
  const { default: Redis } = await import('ioredis')
25
- this.redisClient = new Redis(this.globalConfig.redisUrl, {
25
+ this.redisClient = new Redis({
26
+ ...parseRedisUrl(this.globalConfig.redisUrl),
26
27
  enableOfflineQueue: false,
27
28
  maxRetriesPerRequest: 1,
28
- protocol: REDIS_WIRE_PROTOCOL,
29
29
  })
30
30
  }
31
31
  }
@@ -20,6 +20,21 @@ export type EntityExtension = {
20
20
  baseKey: string // column name on base (e.g., 'id')
21
21
  extensionKey: string // column name on extension (e.g., 'user_id')
22
22
  }
23
+ /**
24
+ * Physical table backing the extension entity.
25
+ *
26
+ * Optional. When omitted the query engine derives the table from the entity
27
+ * segment of `extension` with the same regular pluralizer it uses for every
28
+ * other table-name fallback, so `y` → `ies` is handled:
29
+ * `example:example_customer_priority` derives `example_customer_priorities`.
30
+ * Declare this for the plurals no guesser can win (`person` → `people`) and
31
+ * for any entity whose `@Entity({ tableName })` simply does not match the
32
+ * derived name.
33
+ *
34
+ * MUST be a plain identifier (`[A-Za-z_][A-Za-z0-9_]*`); the engine ignores
35
+ * any other value and falls back to the derived name.
36
+ */
37
+ table?: string
23
38
  cardinality?: 'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many'
24
39
  required?: boolean
25
40
  description?: string