@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.
- package/dist/lib/auth/server.js +2 -2
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/dynamicLoader.js +43 -33
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/crud/factory.js +8 -7
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/openapi/generator.js +8 -4
- package/dist/lib/openapi/generator.js.map +2 -2
- package/dist/lib/query/engine.js +4 -1
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/ratelimit/service.js +4 -4
- package/dist/lib/ratelimit/service.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/widgets/extension-points.js +131 -0
- package/dist/modules/widgets/extension-points.js.map +2 -2
- package/package.json +2 -2
- package/src/lib/auth/__tests__/server.test.ts +61 -0
- package/src/lib/auth/server.ts +12 -2
- package/src/lib/bootstrap/dynamicLoader.ts +97 -43
- package/src/lib/crud/__tests__/crud-factory.test.ts +39 -0
- package/src/lib/crud/factory.ts +19 -9
- package/src/lib/openapi/__tests__/response-precedence.test.ts +150 -0
- package/src/lib/openapi/generator.ts +7 -3
- package/src/lib/query/__tests__/engine.test.ts +62 -0
- package/src/lib/query/engine.ts +19 -1
- package/src/lib/ratelimit/service.ts +3 -3
- package/src/modules/entities.ts +15 -0
- package/src/modules/widgets/extension-points.ts +157 -80
package/src/lib/crud/factory.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|
package/src/lib/query/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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(
|
|
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
|
}
|
package/src/modules/entities.ts
CHANGED
|
@@ -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
|