@open-mercato/shared 0.8.1-develop.7274.1.21a0083828 → 0.8.1-develop.7294.1.0ef99fc518
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/.turbo/turbo-build.log +2 -1
- package/build.mjs +4 -1
- package/dist/lib/crud/errors.js +6 -1
- package/dist/lib/crud/errors.js.map +2 -2
- package/dist/lib/crud/factory.js +22 -13
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/custom-fields/kinds.js +113 -0
- package/dist/lib/custom-fields/kinds.js.map +7 -0
- package/dist/lib/encryption/indexDoc.js +12 -9
- package/dist/lib/encryption/indexDoc.js.map +2 -2
- package/dist/lib/i18n/server.js +1 -1
- package/dist/lib/i18n/server.js.map +2 -2
- package/dist/lib/location/countries.generated.js +265 -0
- package/dist/lib/location/countries.generated.js.map +7 -0
- package/dist/lib/location/countries.js +2 -13
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/navigation/pageReload.js +11 -0
- package/dist/lib/navigation/pageReload.js.map +7 -0
- package/dist/lib/schedule/interval.js +33 -0
- package/dist/lib/schedule/interval.js.map +7 -0
- package/dist/lib/schedule/invalidScheduleValue.js +19 -0
- package/dist/lib/schedule/invalidScheduleValue.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +3 -2
- package/scripts/generate-countries.mjs +75 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +177 -0
- package/src/lib/crud/__tests__/errors.test.ts +31 -1
- package/src/lib/crud/errors.ts +16 -0
- package/src/lib/crud/factory.ts +37 -15
- package/src/lib/custom-fields/__tests__/kinds.test.ts +208 -0
- package/src/lib/custom-fields/kinds.ts +205 -0
- package/src/lib/encryption/__tests__/indexDoc.custom-field-kinds.test.ts +127 -0
- package/src/lib/encryption/__tests__/indexDoc.test.ts +39 -0
- package/src/lib/encryption/indexDoc.ts +16 -7
- package/src/lib/i18n/__tests__/dictionary-locale-fallback.test.ts +49 -0
- package/src/lib/i18n/server.ts +13 -5
- package/src/lib/location/__tests__/countries.test.ts +25 -0
- package/src/lib/location/countries.generated.ts +264 -0
- package/src/lib/location/countries.ts +2 -23
- package/src/lib/navigation/__tests__/pageReload.test.ts +58 -0
- package/src/lib/navigation/pageReload.ts +43 -0
- package/src/lib/schedule/__tests__/interval.test.ts +37 -0
- package/src/lib/schedule/interval.ts +45 -0
- package/src/lib/schedule/invalidScheduleValue.ts +33 -0
package/src/lib/crud/factory.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { resolveOrganizationScopeForRequest, type OrganizationScope } from '@ope
|
|
|
10
10
|
import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
|
|
11
11
|
import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
|
|
12
12
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
13
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
13
14
|
import {
|
|
14
15
|
runMutationGuards,
|
|
15
16
|
bridgeLegacyGuard,
|
|
@@ -41,7 +42,7 @@ import {
|
|
|
41
42
|
type ResolvedCustomFieldDefinitions,
|
|
42
43
|
} from './custom-field-definition-index'
|
|
43
44
|
import { serializeExport, normalizeExportFormat, defaultExportFilename, ensureColumns, type CrudExportFormat, type PreparedExport } from './exporters'
|
|
44
|
-
import { CrudHttpError, isCrudHttpError } from './errors'
|
|
45
|
+
import { CrudHttpError, isCrudHttpError, translateCrudErrorBody } from './errors'
|
|
45
46
|
import type { CommandBus, CommandLogMetadata } from '@open-mercato/shared/lib/commands'
|
|
46
47
|
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
47
48
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
@@ -634,9 +635,12 @@ function classifyCrudError(err: unknown): { code: CrudErrorCode; pgSqlState: str
|
|
|
634
635
|
return { code: 'INTERNAL_ERROR', pgSqlState: null }
|
|
635
636
|
}
|
|
636
637
|
|
|
637
|
-
function handleError(err: unknown, request?: Request): Response {
|
|
638
|
+
async function handleError(err: unknown, request?: Request): Promise<Response> {
|
|
638
639
|
if (err instanceof Response) return err
|
|
639
|
-
if (isCrudHttpError(err))
|
|
640
|
+
if (isCrudHttpError(err)) {
|
|
641
|
+
const { translate } = await resolveTranslations()
|
|
642
|
+
return json(translateCrudErrorBody(err.body, translate), { status: err.status })
|
|
643
|
+
}
|
|
640
644
|
// A command interceptor that blocked with an explicit status is a deliberate business
|
|
641
645
|
// rejection, not a server fault — surface its status and message instead of a generic 500.
|
|
642
646
|
// Without a usable status the error falls through to the historical handling below (issue #5045).
|
|
@@ -2027,15 +2031,25 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2027
2031
|
nextPage += 1
|
|
2028
2032
|
}
|
|
2029
2033
|
}
|
|
2034
|
+
const exportPayload = { items: exportItems, total, page: 1, pageSize: exportItems.length, totalPages: 1, ...(res.meta ? { meta: res.meta } : {}) }
|
|
2035
|
+
// Serialize only after `afterList` has run: the hook is the documented place to
|
|
2036
|
+
// patch values the base query cannot compute, and an export built before it runs
|
|
2037
|
+
// ships a different shape than the JSON list response for the same request (#5969).
|
|
2038
|
+
await opts.hooks?.afterList?.(exportPayload, { ...ctx, query: validated as any })
|
|
2039
|
+
profiler.mark('after_list_hook')
|
|
2040
|
+
const hookExportItems = Array.isArray(exportPayload.items) ? exportPayload.items : exportItems
|
|
2041
|
+
// Re-normalize after the hook: `afterList` can add keys the full-export contract
|
|
2042
|
+
// strips (`_`-prefixed metadata, `cf_*`) or fail to flatten (#6019 review).
|
|
2043
|
+
// Idempotent on records already shaped by normalizeFullRecordForExport above.
|
|
2044
|
+
const finalExportItems = exportFullRequested
|
|
2045
|
+
? hookExportItems.map(normalizeFullRecordForExport)
|
|
2046
|
+
: hookExportItems
|
|
2030
2047
|
const prepared = exportFullRequested
|
|
2031
|
-
? { columns: ensureColumns(
|
|
2032
|
-
: prepareExportData(
|
|
2048
|
+
? { columns: ensureColumns(finalExportItems), rows: finalExportItems }
|
|
2049
|
+
: prepareExportData(finalExportItems, opts.list, validated as any, ctx)
|
|
2033
2050
|
const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
|
|
2034
2051
|
const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
|
|
2035
2052
|
const serialized = serializeExport(prepared, requestedExport)
|
|
2036
|
-
const exportPayload = { items: exportItems, total, page: 1, pageSize: exportItems.length, totalPages: 1, ...(res.meta ? { meta: res.meta } : {}) }
|
|
2037
|
-
await opts.hooks?.afterList?.(exportPayload, { ...ctx, query: validated as any })
|
|
2038
|
-
profiler.mark('after_list_hook')
|
|
2039
2053
|
const response = new Response(serialized.body, {
|
|
2040
2054
|
headers: {
|
|
2041
2055
|
'content-type': serialized.contentType,
|
|
@@ -2058,7 +2072,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2058
2072
|
finishProfile({
|
|
2059
2073
|
result: 'export',
|
|
2060
2074
|
cacheStatus,
|
|
2061
|
-
itemCount:
|
|
2075
|
+
itemCount: finalExportItems.length,
|
|
2062
2076
|
total,
|
|
2063
2077
|
})
|
|
2064
2078
|
return response
|
|
@@ -2230,14 +2244,22 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2230
2244
|
profiler.mark('access_logged', accessLogResult)
|
|
2231
2245
|
if (exportRequested && requestedExport) {
|
|
2232
2246
|
const exportItems = exportFullRequested ? list.map(normalizeFullRecordForExport) : list
|
|
2247
|
+
const exportPayload = { items: exportItems, total: exportItems.length, page: 1, pageSize: exportItems.length, totalPages: 1 }
|
|
2248
|
+
// Same ordering contract as the query-engine export path above (#5969).
|
|
2249
|
+
await opts.hooks?.afterList?.(exportPayload, { ...ctx, query: validated as any })
|
|
2250
|
+
profiler.mark('after_list_hook')
|
|
2251
|
+
const hookExportItems = Array.isArray(exportPayload.items) ? exportPayload.items : exportItems
|
|
2252
|
+
// Re-normalize after the hook: same full-export contract as the query-engine path
|
|
2253
|
+
// above (#6019 review).
|
|
2254
|
+
const finalExportItems = exportFullRequested
|
|
2255
|
+
? hookExportItems.map(normalizeFullRecordForExport)
|
|
2256
|
+
: hookExportItems
|
|
2233
2257
|
const prepared = exportFullRequested
|
|
2234
|
-
? { columns: ensureColumns(
|
|
2235
|
-
: prepareExportData(
|
|
2258
|
+
? { columns: ensureColumns(finalExportItems), rows: finalExportItems }
|
|
2259
|
+
: prepareExportData(finalExportItems, opts.list, validated as any, ctx)
|
|
2236
2260
|
const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
|
|
2237
2261
|
const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
|
|
2238
2262
|
const serialized = serializeExport(prepared, requestedExport)
|
|
2239
|
-
await opts.hooks?.afterList?.({ items: exportItems, total: exportItems.length, page: 1, pageSize: exportItems.length, totalPages: 1 }, { ...ctx, query: validated as any })
|
|
2240
|
-
profiler.mark('after_list_hook')
|
|
2241
2263
|
const response = new Response(serialized.body, {
|
|
2242
2264
|
headers: {
|
|
2243
2265
|
'content-type': serialized.contentType,
|
|
@@ -2247,8 +2269,8 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2247
2269
|
finishProfile({
|
|
2248
2270
|
result: 'export',
|
|
2249
2271
|
cacheStatus,
|
|
2250
|
-
itemCount:
|
|
2251
|
-
total:
|
|
2272
|
+
itemCount: finalExportItems.length,
|
|
2273
|
+
total: finalExportItems.length,
|
|
2252
2274
|
branch: 'fallback',
|
|
2253
2275
|
})
|
|
2254
2276
|
return response
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildCustomFieldKindIndex,
|
|
3
|
+
createCustomFieldKindMapResolver,
|
|
4
|
+
emptyCustomFieldKindIndex,
|
|
5
|
+
mergeCustomFieldKindIndexes,
|
|
6
|
+
resolveCustomFieldKind,
|
|
7
|
+
selectCustomFieldKindMap,
|
|
8
|
+
} from '../kinds'
|
|
9
|
+
|
|
10
|
+
const ORG_A = '11111111-1111-4111-8111-111111111111'
|
|
11
|
+
const ORG_B = '22222222-2222-4222-8222-222222222222'
|
|
12
|
+
const TENANT = '33333333-3333-4333-8333-333333333333'
|
|
13
|
+
|
|
14
|
+
const kindMap = (rows: Parameters<typeof buildCustomFieldKindIndex>[0], orgId: string | null = null, tenantId: string | null = null) =>
|
|
15
|
+
selectCustomFieldKindMap(buildCustomFieldKindIndex(rows), orgId, tenantId)
|
|
16
|
+
|
|
17
|
+
describe('custom-fields/kinds', () => {
|
|
18
|
+
it('indexes a definition under both its authored key and its sanitized alias', () => {
|
|
19
|
+
expect(kindMap([{ key: 'order-ref', kind: 'text' }])).toEqual({ 'order-ref': 'text', order_ref: 'text' })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('lets an authored key win over an alias another definition sanitizes to', () => {
|
|
23
|
+
const aliasFirst = kindMap([
|
|
24
|
+
{ key: 'order-ref', kind: 'text' },
|
|
25
|
+
{ key: 'order_ref', kind: 'integer' },
|
|
26
|
+
])
|
|
27
|
+
const authoredFirst = kindMap([
|
|
28
|
+
{ key: 'order_ref', kind: 'integer' },
|
|
29
|
+
{ key: 'order-ref', kind: 'text' },
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
expect(aliasFirst.order_ref).toBe('integer')
|
|
33
|
+
expect(authoredFirst.order_ref).toBe('integer')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('normalizes rows with blank, non-string, or missing values', () => {
|
|
37
|
+
const map = kindMap([
|
|
38
|
+
{ key: ' padded ', kind: 'text' },
|
|
39
|
+
{ key: 'no-kind' },
|
|
40
|
+
{ key: 'null-kind', kind: null },
|
|
41
|
+
{ key: 'numeric-kind', kind: 7 },
|
|
42
|
+
{ key: ' ' },
|
|
43
|
+
{ key: null },
|
|
44
|
+
{ key: 123, kind: 'text' },
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
expect(map.padded).toBe('text')
|
|
48
|
+
expect(map['no-kind']).toBeNull()
|
|
49
|
+
expect(map['null-kind']).toBeNull()
|
|
50
|
+
expect(map['numeric-kind']).toBeNull()
|
|
51
|
+
expect(map['123']).toBe('text')
|
|
52
|
+
expect(Object.keys(map)).not.toContain('')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('scope resolution', () => {
|
|
56
|
+
// custom_field_defs has no unique constraint on (entity_id, key): an organization may
|
|
57
|
+
// override an inherited definition, and POST /definitions does not force the override's
|
|
58
|
+
// kind to match. Typing a value by the wrong scope's definition would turn an integer
|
|
59
|
+
// custom field into a string on read — the exact regression #5968 set out to avoid.
|
|
60
|
+
const collidingDefs = [
|
|
61
|
+
{ key: 'code', kind: 'integer', organizationId: ORG_A, tenantId: TENANT },
|
|
62
|
+
{ key: 'code', kind: 'text', organizationId: ORG_B, tenantId: TENANT },
|
|
63
|
+
{ key: 'code', kind: 'boolean', organizationId: null, tenantId: null },
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
it('types a key by the definition of the row\'s own organization', () => {
|
|
67
|
+
expect(kindMap(collidingDefs, ORG_A, TENANT).code).toBe('integer')
|
|
68
|
+
expect(kindMap(collidingDefs, ORG_B, TENANT).code).toBe('text')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('is not decided by the order rows came back in', () => {
|
|
72
|
+
const reversed = collidingDefs.slice().reverse()
|
|
73
|
+
|
|
74
|
+
expect(kindMap(reversed, ORG_A, TENANT).code).toBe('integer')
|
|
75
|
+
expect(kindMap(reversed, ORG_B, TENANT).code).toBe('text')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('prefers a tenant-scoped definition over a global one when no organization matches', () => {
|
|
79
|
+
const defs = [
|
|
80
|
+
{ key: 'code', kind: 'boolean', organizationId: null, tenantId: null },
|
|
81
|
+
{ key: 'code', kind: 'float', organizationId: null, tenantId: TENANT },
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
expect(kindMap(defs, null, TENANT).code).toBe('float')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('falls back to the global definition for an organization that defines none', () => {
|
|
88
|
+
const unknownOrg = '44444444-4444-4444-8444-444444444444'
|
|
89
|
+
|
|
90
|
+
expect(kindMap(collidingDefs, unknownOrg, TENANT).code).toBe('boolean')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('breaks a same-scope tie deterministically rather than by row order', () => {
|
|
94
|
+
const defs = [
|
|
95
|
+
{ key: 'code', kind: 'integer', organizationId: ORG_A, tenantId: TENANT, configJson: { priority: 5 } },
|
|
96
|
+
{ key: 'code', kind: 'text', organizationId: ORG_A, tenantId: TENANT, configJson: { priority: 1 } },
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
expect(kindMap(defs, ORG_A, TENANT).code).toBe('text')
|
|
100
|
+
expect(kindMap(defs.slice().reverse(), ORG_A, TENANT).code).toBe('text')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('applies the same scope rule to a sanitized alias as to an authored key', () => {
|
|
104
|
+
const defs = [
|
|
105
|
+
{ key: 'order-ref', kind: 'integer', organizationId: ORG_A, tenantId: TENANT },
|
|
106
|
+
{ key: 'order-ref', kind: 'text', organizationId: ORG_B, tenantId: TENANT },
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
expect(kindMap(defs, ORG_A, TENANT).order_ref).toBe('integer')
|
|
110
|
+
expect(kindMap(defs, ORG_B, TENANT).order_ref).toBe('text')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('createCustomFieldKindMapResolver', () => {
|
|
115
|
+
const index = buildCustomFieldKindIndex([
|
|
116
|
+
{ key: 'code', kind: 'integer', organizationId: ORG_A, tenantId: TENANT },
|
|
117
|
+
{ key: 'code', kind: 'text', organizationId: ORG_B, tenantId: TENANT },
|
|
118
|
+
])
|
|
119
|
+
|
|
120
|
+
it('gives each organization its own map', () => {
|
|
121
|
+
const resolve = createCustomFieldKindMapResolver(index)
|
|
122
|
+
|
|
123
|
+
expect(resolve(ORG_A, TENANT).code).toBe('integer')
|
|
124
|
+
expect(resolve(ORG_B, TENANT).code).toBe('text')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('reuses the map for a repeated scope', () => {
|
|
128
|
+
const resolve = createCustomFieldKindMapResolver(index)
|
|
129
|
+
|
|
130
|
+
expect(resolve(ORG_A, TENANT)).toBe(resolve(ORG_A, TENANT))
|
|
131
|
+
expect(resolve(ORG_A, TENANT)).not.toBe(resolve(ORG_B, TENANT))
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('yields an empty map for an absent index', () => {
|
|
135
|
+
expect(createCustomFieldKindMapResolver(null)(ORG_A, TENANT)).toEqual({})
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('mergeCustomFieldKindIndexes', () => {
|
|
140
|
+
it('lets the earliest source win when several define the same key', () => {
|
|
141
|
+
// The query engine reads a cf value with `coalesce(source0, source1, ...)`, so the kind
|
|
142
|
+
// has to come from the same source the value did.
|
|
143
|
+
const base = buildCustomFieldKindIndex([{ key: 'code', kind: 'text' }])
|
|
144
|
+
const joined = buildCustomFieldKindIndex([
|
|
145
|
+
{ key: 'code', kind: 'integer' },
|
|
146
|
+
{ key: 'extra', kind: 'boolean' },
|
|
147
|
+
])
|
|
148
|
+
|
|
149
|
+
expect(selectCustomFieldKindMap(mergeCustomFieldKindIndexes([base, joined]), null, null))
|
|
150
|
+
.toEqual({ code: 'text', extra: 'boolean' })
|
|
151
|
+
expect(selectCustomFieldKindMap(mergeCustomFieldKindIndexes([joined, base]), null, null))
|
|
152
|
+
.toEqual({ code: 'integer', extra: 'boolean' })
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('keeps the earlier source winning per scope, not just for the default one', () => {
|
|
156
|
+
const base = buildCustomFieldKindIndex([
|
|
157
|
+
{ key: 'code', kind: 'text', organizationId: ORG_A, tenantId: TENANT },
|
|
158
|
+
])
|
|
159
|
+
const joined = buildCustomFieldKindIndex([
|
|
160
|
+
{ key: 'code', kind: 'integer', organizationId: ORG_A, tenantId: TENANT },
|
|
161
|
+
])
|
|
162
|
+
|
|
163
|
+
expect(selectCustomFieldKindMap(mergeCustomFieldKindIndexes([base, joined]), ORG_A, TENANT).code).toBe('text')
|
|
164
|
+
expect(selectCustomFieldKindMap(mergeCustomFieldKindIndexes([joined, base]), ORG_A, TENANT).code).toBe('integer')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('ignores absent sources when merging', () => {
|
|
168
|
+
const index = buildCustomFieldKindIndex([{ key: 'code', kind: 'text' }])
|
|
169
|
+
|
|
170
|
+
expect(selectCustomFieldKindMap(
|
|
171
|
+
mergeCustomFieldKindIndexes([null, index, undefined, emptyCustomFieldKindIndex()]), null, null,
|
|
172
|
+
)).toEqual({ code: 'text' })
|
|
173
|
+
expect(selectCustomFieldKindMap(mergeCustomFieldKindIndexes([]), null, null)).toEqual({})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('keeps a null kind from an earlier source instead of falling through', () => {
|
|
177
|
+
const base = buildCustomFieldKindIndex([{ key: 'code' }])
|
|
178
|
+
const joined = buildCustomFieldKindIndex([{ key: 'code', kind: 'integer' }])
|
|
179
|
+
|
|
180
|
+
expect(selectCustomFieldKindMap(mergeCustomFieldKindIndexes([base, joined]), null, null).code).toBeNull()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('resolveCustomFieldKind', () => {
|
|
185
|
+
it('resolves both index-document key shapes', () => {
|
|
186
|
+
const map = kindMap([{ key: 'note', kind: 'multiline' }])
|
|
187
|
+
|
|
188
|
+
expect(resolveCustomFieldKind(map, 'cf:note')).toBe('multiline')
|
|
189
|
+
expect(resolveCustomFieldKind(map, 'cf_note')).toBe('multiline')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('resolves a sanitized document key back to its authored definition', () => {
|
|
193
|
+
const map = kindMap([{ key: 'order-ref', kind: 'text' }])
|
|
194
|
+
|
|
195
|
+
expect(resolveCustomFieldKind(map, 'cf_order_ref')).toBe('text')
|
|
196
|
+
expect(resolveCustomFieldKind(map, 'cf:order-ref')).toBe('text')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('returns null for an unknown key, an absent map, or a bare prefix', () => {
|
|
200
|
+
const map = kindMap([{ key: 'note', kind: 'text' }])
|
|
201
|
+
|
|
202
|
+
expect(resolveCustomFieldKind(map, 'cf:missing')).toBeNull()
|
|
203
|
+
expect(resolveCustomFieldKind(null, 'cf:note')).toBeNull()
|
|
204
|
+
expect(resolveCustomFieldKind(undefined, 'cf:note')).toBeNull()
|
|
205
|
+
expect(resolveCustomFieldKind(map, 'cf:')).toBeNull()
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom-field `kind` lookup for the `cf:`/`cf_` keys carried by a query-index document.
|
|
3
|
+
*
|
|
4
|
+
* `decryptCustomFieldValue` cannot recover a value's original JS type from the ciphertext
|
|
5
|
+
* alone: the encrypt path stores a raw string unwrapped and JSON-stringifies everything
|
|
6
|
+
* else, so the string `"123"` and the number `123` produce the same plaintext. The field's
|
|
7
|
+
* `kind` is the only disambiguator, which is why every index-document read path has to
|
|
8
|
+
* resolve it before decrypting (issue #5968).
|
|
9
|
+
*
|
|
10
|
+
* `custom_field_defs` has no unique constraint on `(entity_id, key)` — per-scope overrides
|
|
11
|
+
* are a first-class feature, and an organization's override may declare a different `kind`
|
|
12
|
+
* than the tenant-wide or global definition it shadows. A flat "last row wins" map would
|
|
13
|
+
* therefore type a value from whichever definition the database happened to return last.
|
|
14
|
+
* So the index keeps every candidate definition per key and resolves the winner against the
|
|
15
|
+
* scope of the row being decrypted, through the same `selectDefinitionForRecord` precedence
|
|
16
|
+
* the non-indexed loader in `crud/custom-fields.ts` already applies per record.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
selectDefinitionForRecord,
|
|
20
|
+
sortDefinitionSummaries,
|
|
21
|
+
summarizeDefinitionRow,
|
|
22
|
+
type CustomFieldDefinitionSummary,
|
|
23
|
+
} from '../crud/custom-field-definition-index'
|
|
24
|
+
|
|
25
|
+
export type CustomFieldKindMap = Record<string, string | null>
|
|
26
|
+
|
|
27
|
+
export type CustomFieldKindRow = {
|
|
28
|
+
key: unknown
|
|
29
|
+
kind?: unknown
|
|
30
|
+
organizationId?: unknown
|
|
31
|
+
tenantId?: unknown
|
|
32
|
+
configJson?: unknown
|
|
33
|
+
updatedAt?: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Candidate definitions per document key. `keys` holds the authored keys; `aliases` holds
|
|
38
|
+
* the SQL-safe aliases only, and is consulted solely when the document key matches no
|
|
39
|
+
* authored key — so a literal `my_field` definition is never shadowed by the alias a
|
|
40
|
+
* `my-field` definition also sanitizes to.
|
|
41
|
+
*/
|
|
42
|
+
export type CustomFieldKindIndex = {
|
|
43
|
+
keys: Map<string, CustomFieldDefinitionSummary[]>
|
|
44
|
+
aliases: Map<string, CustomFieldDefinitionSummary[]>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolves the kind map that applies to a row in the given scope. */
|
|
48
|
+
export type CustomFieldKindMapResolver = (
|
|
49
|
+
organizationId: string | null | undefined,
|
|
50
|
+
tenantId: string | null | undefined,
|
|
51
|
+
) => CustomFieldKindMap
|
|
52
|
+
|
|
53
|
+
const CF_KEY_PREFIX = /^cf[:_]/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mirrors `HybridQueryEngine.sanitize()`, which aliases the doc key `cf:my-field` as the
|
|
57
|
+
* SQL-safe column alias `cf_my_field`. The transform is lossy, so an index keyed only by the
|
|
58
|
+
* authored key would never match a sanitized alias.
|
|
59
|
+
*/
|
|
60
|
+
function sanitizeKey(key: string): string {
|
|
61
|
+
return key.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeKey(value: unknown): string {
|
|
65
|
+
if (typeof value === 'string') return value.trim()
|
|
66
|
+
if (value == null) return ''
|
|
67
|
+
return String(value).trim()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeScopeId(value: unknown): string | null {
|
|
71
|
+
if (typeof value !== 'string') return null
|
|
72
|
+
const trimmed = value.trim()
|
|
73
|
+
return trimmed.length ? trimmed : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeTimestamp(value: unknown): Date | string | number | null {
|
|
77
|
+
if (value instanceof Date) return value
|
|
78
|
+
if (typeof value === 'string' || typeof value === 'number') return value
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pushCandidate(
|
|
83
|
+
bucket: Map<string, CustomFieldDefinitionSummary[]>,
|
|
84
|
+
key: string,
|
|
85
|
+
summary: CustomFieldDefinitionSummary,
|
|
86
|
+
): void {
|
|
87
|
+
const existing = bucket.get(key)
|
|
88
|
+
if (existing) existing.push(summary)
|
|
89
|
+
else bucket.set(key, [summary])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function buildCustomFieldKindIndex(rows: Iterable<CustomFieldKindRow>): CustomFieldKindIndex {
|
|
93
|
+
const keys = new Map<string, CustomFieldDefinitionSummary[]>()
|
|
94
|
+
const aliases = new Map<string, CustomFieldDefinitionSummary[]>()
|
|
95
|
+
for (const row of rows) {
|
|
96
|
+
const key = normalizeKey(row.key)
|
|
97
|
+
if (!key) continue
|
|
98
|
+
const summary = summarizeDefinitionRow({
|
|
99
|
+
key,
|
|
100
|
+
entityId: '',
|
|
101
|
+
kind: typeof row.kind === 'string' && row.kind.length ? row.kind : null,
|
|
102
|
+
configJson: row.configJson ?? null,
|
|
103
|
+
organizationId: normalizeScopeId(row.organizationId),
|
|
104
|
+
tenantId: normalizeScopeId(row.tenantId),
|
|
105
|
+
deletedAt: null,
|
|
106
|
+
updatedAt: normalizeTimestamp(row.updatedAt),
|
|
107
|
+
})
|
|
108
|
+
if (!summary) continue
|
|
109
|
+
pushCandidate(keys, key, summary)
|
|
110
|
+
const alias = sanitizeKey(key)
|
|
111
|
+
if (alias !== key) pushCandidate(aliases, alias, summary)
|
|
112
|
+
}
|
|
113
|
+
// One precedence rule everywhere: `selectDefinitionForRecord` picks the scope winner and
|
|
114
|
+
// `sortDefinitionSummaries` breaks ties deterministically, for authored keys and aliases
|
|
115
|
+
// alike, so no bucket depends on the order rows arrived in.
|
|
116
|
+
for (const [key, candidates] of keys) keys.set(key, sortDefinitionSummaries(candidates))
|
|
117
|
+
for (const [alias, candidates] of aliases) aliases.set(alias, sortDefinitionSummaries(candidates))
|
|
118
|
+
return { keys, aliases }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function emptyCustomFieldKindIndex(): CustomFieldKindIndex {
|
|
122
|
+
return { keys: new Map(), aliases: new Map() }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Merges per-source indexes into one, with earlier sources winning a key outright. Callers
|
|
127
|
+
* pass their sources in the same priority order the value itself resolves in, so a key
|
|
128
|
+
* defined by more than one source is typed by the source the value actually came from.
|
|
129
|
+
*/
|
|
130
|
+
export function mergeCustomFieldKindIndexes(
|
|
131
|
+
indexes: Array<CustomFieldKindIndex | null | undefined>,
|
|
132
|
+
): CustomFieldKindIndex {
|
|
133
|
+
const merged = emptyCustomFieldKindIndex()
|
|
134
|
+
for (const index of indexes) {
|
|
135
|
+
if (!index) continue
|
|
136
|
+
for (const [key, candidates] of index.keys) {
|
|
137
|
+
if (!merged.keys.has(key)) merged.keys.set(key, candidates)
|
|
138
|
+
}
|
|
139
|
+
for (const [alias, candidates] of index.aliases) {
|
|
140
|
+
if (!merged.aliases.has(alias)) merged.aliases.set(alias, candidates)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return merged
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Flattens the index to the kind map that applies to one scope. The winner per key is the
|
|
148
|
+
* organization's own definition, then a tenant-scoped one, then a global one — the order
|
|
149
|
+
* `selectDefinitionForRecord` encodes and the whole codebase resolves definitions by.
|
|
150
|
+
*/
|
|
151
|
+
export function selectCustomFieldKindMap(
|
|
152
|
+
index: CustomFieldKindIndex | null | undefined,
|
|
153
|
+
organizationId: string | null | undefined,
|
|
154
|
+
tenantId: string | null | undefined,
|
|
155
|
+
): CustomFieldKindMap {
|
|
156
|
+
const map: CustomFieldKindMap = {}
|
|
157
|
+
if (!index) return map
|
|
158
|
+
const orgId = organizationId ?? null
|
|
159
|
+
const tenant = tenantId ?? null
|
|
160
|
+
for (const [key, candidates] of index.keys) {
|
|
161
|
+
map[key] = selectDefinitionForRecord(candidates, orgId, tenant)?.kind ?? null
|
|
162
|
+
}
|
|
163
|
+
for (const [alias, candidates] of index.aliases) {
|
|
164
|
+
if (alias in map) continue
|
|
165
|
+
map[alias] = selectDefinitionForRecord(candidates, orgId, tenant)?.kind ?? null
|
|
166
|
+
}
|
|
167
|
+
return map
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Memoizing wrapper for the common read shape: many rows, few distinct scopes. A list query
|
|
172
|
+
* whose rows all share one organization resolves the map once; a global search spanning
|
|
173
|
+
* organizations resolves one map per organization rather than one per row.
|
|
174
|
+
*/
|
|
175
|
+
export function createCustomFieldKindMapResolver(
|
|
176
|
+
index: CustomFieldKindIndex | null | undefined,
|
|
177
|
+
): CustomFieldKindMapResolver {
|
|
178
|
+
const cache = new Map<string, CustomFieldKindMap>()
|
|
179
|
+
return (organizationId, tenantId) => {
|
|
180
|
+
const scopeKey = `${organizationId ?? ''}::${tenantId ?? ''}`
|
|
181
|
+
const cached = cache.get(scopeKey)
|
|
182
|
+
if (cached) return cached
|
|
183
|
+
const map = selectCustomFieldKindMap(index, organizationId, tenantId)
|
|
184
|
+
cache.set(scopeKey, map)
|
|
185
|
+
return map
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Resolves the `kind` for a `cf:`/`cf_`-prefixed index-document key. Returns `null` when the
|
|
191
|
+
* key has no active definition — callers then keep the historical no-`kind` behavior rather
|
|
192
|
+
* than guessing, so an unresolved lookup degrades instead of corrupting the value.
|
|
193
|
+
*/
|
|
194
|
+
export function resolveCustomFieldKind(
|
|
195
|
+
kinds: CustomFieldKindMap | null | undefined,
|
|
196
|
+
docKey: string,
|
|
197
|
+
): string | null {
|
|
198
|
+
if (!kinds) return null
|
|
199
|
+
const bare = docKey.replace(CF_KEY_PREFIX, '')
|
|
200
|
+
if (!bare) return null
|
|
201
|
+
if (bare in kinds) return kinds[bare] ?? null
|
|
202
|
+
const alias = sanitizeKey(bare)
|
|
203
|
+
if (alias !== bare && alias in kinds) return kinds[alias] ?? null
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { decryptIndexDocCustomFields, decryptIndexDocForSearch } from '../indexDoc'
|
|
2
|
+
import { encryptCustomFieldValue } from '../customFieldValues'
|
|
3
|
+
import {
|
|
4
|
+
buildCustomFieldKindIndex,
|
|
5
|
+
selectCustomFieldKindMap,
|
|
6
|
+
type CustomFieldKindRow,
|
|
7
|
+
} from '../../custom-fields/kinds'
|
|
8
|
+
|
|
9
|
+
function buildCustomFieldKindMap(rows: CustomFieldKindRow[]) {
|
|
10
|
+
return selectCustomFieldKindMap(buildCustomFieldKindIndex(rows), null, null)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Regression coverage for issue #5968: the query-index and search read paths decrypted
|
|
15
|
+
* `cf:`/`cf_` values without the field's `kind`, so `decryptCustomFieldValue` fell through
|
|
16
|
+
* to `JSON.parse` and retyped every string-typed custom field whose plaintext looked like
|
|
17
|
+
* JSON. Unlike the sibling `indexDoc.test.ts`, this suite does NOT mock
|
|
18
|
+
* `decryptCustomFieldValue` — the defect lives in the argument the helpers pass to it, so a
|
|
19
|
+
* mocked value would hide it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fixedKey = Buffer.alloc(32, 7).toString('base64')
|
|
23
|
+
|
|
24
|
+
const service = {
|
|
25
|
+
isEnabled: () => true,
|
|
26
|
+
getDek: async () => ({ key: fixedKey }),
|
|
27
|
+
} as any
|
|
28
|
+
|
|
29
|
+
const scope = { tenantId: 'tenant-1', organizationId: 'org-1' }
|
|
30
|
+
|
|
31
|
+
async function encrypt(value: unknown): Promise<unknown> {
|
|
32
|
+
return encryptCustomFieldValue(value, scope.tenantId, service, new Map())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('encryption/indexDoc custom field kinds (#5968)', () => {
|
|
36
|
+
it.each([
|
|
37
|
+
['123', 'text'],
|
|
38
|
+
['0042', 'multiline'],
|
|
39
|
+
['true', 'select'],
|
|
40
|
+
['false', 'text'],
|
|
41
|
+
['null', 'text'],
|
|
42
|
+
['{"nested":1}', 'multiline'],
|
|
43
|
+
['[1,2,3]', 'text'],
|
|
44
|
+
['1.5e3', 'currency'],
|
|
45
|
+
])('keeps the string %p verbatim for a %s field', async (plaintext, kind) => {
|
|
46
|
+
const doc = { id: '1', 'cf:note': await encrypt(plaintext) }
|
|
47
|
+
const kinds = buildCustomFieldKindMap([{ key: 'note', kind }])
|
|
48
|
+
|
|
49
|
+
const out = await decryptIndexDocCustomFields(doc, scope, service, new Map(), kinds)
|
|
50
|
+
|
|
51
|
+
expect(out['cf:note']).toBe(plaintext)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('still coerces numeric and boolean kinds through the JSON round trip', async () => {
|
|
55
|
+
const doc = {
|
|
56
|
+
id: '1',
|
|
57
|
+
'cf:count': await encrypt(42),
|
|
58
|
+
'cf:ratio': await encrypt(3.14),
|
|
59
|
+
'cf:flag': await encrypt(true),
|
|
60
|
+
}
|
|
61
|
+
const kinds = buildCustomFieldKindMap([
|
|
62
|
+
{ key: 'count', kind: 'integer' },
|
|
63
|
+
{ key: 'ratio', kind: 'float' },
|
|
64
|
+
{ key: 'flag', kind: 'boolean' },
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
const out = await decryptIndexDocCustomFields(doc, scope, service, new Map(), kinds)
|
|
68
|
+
|
|
69
|
+
expect(out['cf:count']).toBe(42)
|
|
70
|
+
expect(out['cf:ratio']).toBe(3.14)
|
|
71
|
+
expect(out['cf:flag']).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('applies the kind to every entry of a multi-value field', async () => {
|
|
75
|
+
const doc = { id: '1', 'cf:codes': [await encrypt('1'), await encrypt('2')] }
|
|
76
|
+
const kinds = buildCustomFieldKindMap([{ key: 'codes', kind: 'text' }])
|
|
77
|
+
|
|
78
|
+
const out = await decryptIndexDocCustomFields(doc, scope, service, new Map(), kinds)
|
|
79
|
+
|
|
80
|
+
expect(out['cf:codes']).toEqual(['1', '2'])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('resolves the sanitized `cf_` alias the hybrid query engine emits', async () => {
|
|
84
|
+
// `HybridQueryEngine.sanitize()` turns the doc key `cf:order-ref` into `cf_order_ref`,
|
|
85
|
+
// so a map keyed only by the authored key would miss it.
|
|
86
|
+
const doc = { id: '1', cf_order_ref: await encrypt('900') }
|
|
87
|
+
const kinds = buildCustomFieldKindMap([{ key: 'order-ref', kind: 'text' }])
|
|
88
|
+
|
|
89
|
+
const out = await decryptIndexDocCustomFields(doc, scope, service, new Map(), kinds)
|
|
90
|
+
|
|
91
|
+
expect(out.cf_order_ref).toBe('900')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('falls back to the legacy JSON round trip when the kind cannot be resolved', async () => {
|
|
95
|
+
const doc = { id: '1', 'cf:unknown': await encrypt('123') }
|
|
96
|
+
|
|
97
|
+
const withoutMap = await decryptIndexDocCustomFields(doc, scope, service, new Map())
|
|
98
|
+
const withEmptyMap = await decryptIndexDocCustomFields(doc, scope, service, new Map(), {})
|
|
99
|
+
|
|
100
|
+
expect(withoutMap['cf:unknown']).toBe(123)
|
|
101
|
+
expect(withEmptyMap['cf:unknown']).toBe(123)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('threads the kind map through decryptIndexDocForSearch', async () => {
|
|
105
|
+
const searchService = {
|
|
106
|
+
...service,
|
|
107
|
+
isEnabled: () => true,
|
|
108
|
+
decryptEntityPayload: async () => ({ title: 'Plain' }),
|
|
109
|
+
} as any
|
|
110
|
+
const doc = { id: '1', title: 'Encrypted', 'cf:note': await encrypt('123') }
|
|
111
|
+
const kinds = buildCustomFieldKindMap([{ key: 'note', kind: 'text' }])
|
|
112
|
+
|
|
113
|
+
const out = await decryptIndexDocForSearch('example:todo', doc, scope, searchService, new Map(), kinds)
|
|
114
|
+
|
|
115
|
+
expect(out.title).toBe('Plain')
|
|
116
|
+
expect(out['cf:note']).toBe('123')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('threads the kind map through decryptIndexDocForSearch without an entity payload decryptor', async () => {
|
|
120
|
+
const doc = { id: '1', 'cf:note': await encrypt('true') }
|
|
121
|
+
const kinds = buildCustomFieldKindMap([{ key: 'note', kind: 'text' }])
|
|
122
|
+
|
|
123
|
+
const out = await decryptIndexDocForSearch('example:todo', doc, scope, service, new Map(), kinds)
|
|
124
|
+
|
|
125
|
+
expect(out['cf:note']).toBe('true')
|
|
126
|
+
})
|
|
127
|
+
})
|