@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.
Files changed (45) hide show
  1. package/.turbo/turbo-build.log +2 -1
  2. package/build.mjs +4 -1
  3. package/dist/lib/crud/errors.js +6 -1
  4. package/dist/lib/crud/errors.js.map +2 -2
  5. package/dist/lib/crud/factory.js +22 -13
  6. package/dist/lib/crud/factory.js.map +2 -2
  7. package/dist/lib/custom-fields/kinds.js +113 -0
  8. package/dist/lib/custom-fields/kinds.js.map +7 -0
  9. package/dist/lib/encryption/indexDoc.js +12 -9
  10. package/dist/lib/encryption/indexDoc.js.map +2 -2
  11. package/dist/lib/i18n/server.js +1 -1
  12. package/dist/lib/i18n/server.js.map +2 -2
  13. package/dist/lib/location/countries.generated.js +265 -0
  14. package/dist/lib/location/countries.generated.js.map +7 -0
  15. package/dist/lib/location/countries.js +2 -13
  16. package/dist/lib/location/countries.js.map +2 -2
  17. package/dist/lib/navigation/pageReload.js +11 -0
  18. package/dist/lib/navigation/pageReload.js.map +7 -0
  19. package/dist/lib/schedule/interval.js +33 -0
  20. package/dist/lib/schedule/interval.js.map +7 -0
  21. package/dist/lib/schedule/invalidScheduleValue.js +19 -0
  22. package/dist/lib/schedule/invalidScheduleValue.js.map +7 -0
  23. package/dist/lib/version.js +1 -1
  24. package/dist/lib/version.js.map +1 -1
  25. package/package.json +3 -2
  26. package/scripts/generate-countries.mjs +75 -0
  27. package/src/lib/crud/__tests__/crud-factory.test.ts +177 -0
  28. package/src/lib/crud/__tests__/errors.test.ts +31 -1
  29. package/src/lib/crud/errors.ts +16 -0
  30. package/src/lib/crud/factory.ts +37 -15
  31. package/src/lib/custom-fields/__tests__/kinds.test.ts +208 -0
  32. package/src/lib/custom-fields/kinds.ts +205 -0
  33. package/src/lib/encryption/__tests__/indexDoc.custom-field-kinds.test.ts +127 -0
  34. package/src/lib/encryption/__tests__/indexDoc.test.ts +39 -0
  35. package/src/lib/encryption/indexDoc.ts +16 -7
  36. package/src/lib/i18n/__tests__/dictionary-locale-fallback.test.ts +49 -0
  37. package/src/lib/i18n/server.ts +13 -5
  38. package/src/lib/location/__tests__/countries.test.ts +25 -0
  39. package/src/lib/location/countries.generated.ts +264 -0
  40. package/src/lib/location/countries.ts +2 -23
  41. package/src/lib/navigation/__tests__/pageReload.test.ts +58 -0
  42. package/src/lib/navigation/pageReload.ts +43 -0
  43. package/src/lib/schedule/__tests__/interval.test.ts +37 -0
  44. package/src/lib/schedule/interval.ts +45 -0
  45. package/src/lib/schedule/invalidScheduleValue.ts +33 -0
@@ -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)) return json(err.body, { status: err.status })
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(exportItems), rows: exportItems }
2032
- : prepareExportData(exportItems, opts.list, validated as any, ctx)
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: exportItems.length,
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(exportItems), rows: exportItems }
2235
- : prepareExportData(exportItems, opts.list, validated as any, ctx)
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: exportItems.length,
2251
- total: exportItems.length,
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
+ })