@open-mercato/shared 0.6.7-develop.6860.1.f796b22e80 → 0.6.7

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 (199) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +6 -57
  3. package/build.mjs +4 -2
  4. package/dist/lib/auth/server.js +2 -15
  5. package/dist/lib/auth/server.js.map +2 -2
  6. package/dist/lib/bootstrap/dynamicLoader.js +43 -117
  7. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  8. package/dist/lib/commands/command-bus.js +1 -5
  9. package/dist/lib/commands/command-bus.js.map +2 -2
  10. package/dist/lib/commands/command-interceptor-runner.js +2 -2
  11. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  12. package/dist/lib/crud/enricher-runner.js +11 -2
  13. package/dist/lib/crud/enricher-runner.js.map +2 -2
  14. package/dist/lib/crud/factory.js +4 -63
  15. package/dist/lib/crud/factory.js.map +2 -2
  16. package/dist/lib/crud/interceptor-runner.js +2 -2
  17. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  18. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  19. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  20. package/dist/lib/crud/types.js +0 -4
  21. package/dist/lib/crud/types.js.map +3 -3
  22. package/dist/lib/data/engine.js +11 -37
  23. package/dist/lib/data/engine.js.map +2 -2
  24. package/dist/lib/db/mikro.js +12 -3
  25. package/dist/lib/db/mikro.js.map +2 -2
  26. package/dist/lib/db/pg-errors.js +0 -42
  27. package/dist/lib/db/pg-errors.js.map +2 -2
  28. package/dist/lib/di/container.js +6 -48
  29. package/dist/lib/di/container.js.map +2 -2
  30. package/dist/lib/email/send.js +0 -56
  31. package/dist/lib/email/send.js.map +2 -2
  32. package/dist/lib/encryption/subscriber.js +10 -2
  33. package/dist/lib/encryption/subscriber.js.map +2 -2
  34. package/dist/lib/encryption/tenantDataEncryptionService.js +17 -52
  35. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  36. package/dist/lib/i18n/config.js +1 -1
  37. package/dist/lib/i18n/config.js.map +2 -2
  38. package/dist/lib/i18n/server.js +4 -4
  39. package/dist/lib/i18n/server.js.map +3 -3
  40. package/dist/lib/logger/index.js +3 -47
  41. package/dist/lib/logger/index.js.map +3 -3
  42. package/dist/lib/modules/registry.js +1 -5
  43. package/dist/lib/modules/registry.js.map +2 -2
  44. package/dist/lib/query/engine.js +40 -54
  45. package/dist/lib/query/engine.js.map +2 -2
  46. package/dist/lib/query/types.js.map +1 -1
  47. package/dist/lib/redis/connection.js +2 -7
  48. package/dist/lib/redis/connection.js.map +2 -2
  49. package/dist/lib/search/config.js +2 -61
  50. package/dist/lib/search/config.js.map +2 -2
  51. package/dist/lib/search/tokenize.js +11 -23
  52. package/dist/lib/search/tokenize.js.map +2 -2
  53. package/dist/lib/version.js +1 -1
  54. package/dist/lib/version.js.map +1 -1
  55. package/dist/lib/webhooks/index.js +1 -13
  56. package/dist/lib/webhooks/index.js.map +2 -2
  57. package/dist/lib/webhooks/verify.js +7 -12
  58. package/dist/lib/webhooks/verify.js.map +2 -2
  59. package/dist/modules/analytics.js.map +2 -2
  60. package/dist/modules/overrides.js +1 -50
  61. package/dist/modules/overrides.js.map +2 -2
  62. package/dist/modules/payment_gateways/types.js +1 -8
  63. package/dist/modules/payment_gateways/types.js.map +2 -2
  64. package/dist/modules/registry.js +0 -1
  65. package/dist/modules/registry.js.map +2 -2
  66. package/dist/security/enabledModulesRegistry.js +11 -56
  67. package/dist/security/enabledModulesRegistry.js.map +2 -2
  68. package/jest.config.cjs +1 -2
  69. package/package.json +9 -19
  70. package/src/lib/ai/llm-provider.ts +0 -33
  71. package/src/lib/auth/__tests__/server.test.ts +0 -23
  72. package/src/lib/auth/server.ts +2 -23
  73. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +8 -63
  74. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +1 -21
  75. package/src/lib/bootstrap/dynamicLoader.ts +65 -213
  76. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +0 -28
  77. package/src/lib/commands/command-bus.ts +1 -5
  78. package/src/lib/commands/command-interceptor-runner.ts +2 -2
  79. package/src/lib/crud/__tests__/crud-factory.test.ts +0 -298
  80. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +0 -26
  81. package/src/lib/crud/enricher-runner.ts +11 -2
  82. package/src/lib/crud/factory.ts +1 -69
  83. package/src/lib/crud/interceptor-runner.ts +2 -2
  84. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  85. package/src/lib/crud/types.ts +0 -3
  86. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +3 -18
  87. package/src/lib/data/engine.ts +16 -50
  88. package/src/lib/db/__tests__/mikro.test.ts +25 -0
  89. package/src/lib/db/mikro.ts +38 -13
  90. package/src/lib/db/pg-errors.ts +0 -58
  91. package/src/lib/di/container.ts +8 -72
  92. package/src/lib/email/__tests__/send.test.ts +1 -37
  93. package/src/lib/email/send.ts +0 -79
  94. package/src/lib/encryption/__tests__/subscriber.test.ts +2 -54
  95. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +0 -58
  96. package/src/lib/encryption/subscriber.ts +14 -2
  97. package/src/lib/encryption/tenantDataEncryptionService.ts +18 -64
  98. package/src/lib/i18n/config.ts +2 -2
  99. package/src/lib/i18n/server.ts +2 -6
  100. package/src/lib/logger/__tests__/logger.test.ts +0 -77
  101. package/src/lib/logger/index.ts +1 -59
  102. package/src/lib/modules/registry.ts +0 -10
  103. package/src/lib/query/__tests__/engine.test.ts +13 -65
  104. package/src/lib/query/engine.ts +58 -75
  105. package/src/lib/query/types.ts +5 -4
  106. package/src/lib/redis/__tests__/connection.test.ts +0 -16
  107. package/src/lib/redis/connection.ts +7 -10
  108. package/src/lib/search/__tests__/config.test.ts +0 -177
  109. package/src/lib/search/config.ts +8 -106
  110. package/src/lib/search/tokenize.ts +11 -33
  111. package/src/lib/webhooks/__tests__/verify.test.ts +1 -20
  112. package/src/lib/webhooks/index.ts +1 -19
  113. package/src/lib/webhooks/verify.ts +9 -17
  114. package/src/modules/__tests__/registry.test.ts +2 -10
  115. package/src/modules/analytics.ts +0 -7
  116. package/src/modules/customer-auth.ts +0 -1
  117. package/src/modules/encryption.ts +0 -3
  118. package/src/modules/navigation/backendChrome.ts +0 -15
  119. package/src/modules/overrides.ts +0 -103
  120. package/src/modules/payment_gateways/types.ts +0 -41
  121. package/src/modules/registry.ts +0 -3
  122. package/src/security/enabledModulesRegistry.ts +12 -64
  123. package/dist/lib/ai/safety-identifier.js +0 -32
  124. package/dist/lib/ai/safety-identifier.js.map +0 -7
  125. package/dist/lib/auth/organizationScope.js +0 -34
  126. package/dist/lib/auth/organizationScope.js.map +0 -7
  127. package/dist/lib/bootstrap/clientOnlyModules.js +0 -55
  128. package/dist/lib/bootstrap/clientOnlyModules.js.map +0 -7
  129. package/dist/lib/data/consistency.js +0 -19
  130. package/dist/lib/data/consistency.js.map +0 -7
  131. package/dist/lib/db/entityMetadata.js +0 -26
  132. package/dist/lib/db/entityMetadata.js.map +0 -7
  133. package/dist/lib/logger/extension.js +0 -29
  134. package/dist/lib/logger/extension.js.map +0 -7
  135. package/dist/lib/modules/surfaceFingerprint.js +0 -47
  136. package/dist/lib/modules/surfaceFingerprint.js.map +0 -7
  137. package/dist/lib/query/ciphertext-search-warning.js +0 -45
  138. package/dist/lib/query/ciphertext-search-warning.js.map +0 -7
  139. package/dist/lib/search/auto-indexing.js +0 -14
  140. package/dist/lib/search/auto-indexing.js.map +0 -7
  141. package/dist/lib/search/availability.js +0 -107
  142. package/dist/lib/search/availability.js.map +0 -7
  143. package/dist/lib/search/tokenLookup.js +0 -46
  144. package/dist/lib/search/tokenLookup.js.map +0 -7
  145. package/dist/lib/telemetry/runtime.js +0 -35
  146. package/dist/lib/telemetry/runtime.js.map +0 -7
  147. package/dist/lib/webhooks/body.js +0 -57
  148. package/dist/lib/webhooks/body.js.map +0 -7
  149. package/dist/lib/webhooks/inbound-types.js +0 -1
  150. package/dist/lib/webhooks/inbound-types.js.map +0 -7
  151. package/dist/modules/widgets/extension-points.js +0 -96
  152. package/dist/modules/widgets/extension-points.js.map +0 -7
  153. package/dist/security/featurePolicy.js +0 -62
  154. package/dist/security/featurePolicy.js.map +0 -7
  155. package/scripts/versionSource.cjs +0 -65
  156. package/src/lib/__tests__/versionSource.test.ts +0 -46
  157. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +0 -59
  158. package/src/lib/ai/__tests__/safety-identifier.test.ts +0 -71
  159. package/src/lib/ai/safety-identifier.ts +0 -73
  160. package/src/lib/auth/__tests__/organizationScope.test.ts +0 -107
  161. package/src/lib/auth/organizationScope.ts +0 -65
  162. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +0 -189
  163. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +0 -98
  164. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +0 -211
  165. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +0 -192
  166. package/src/lib/bootstrap/clientOnlyModules.ts +0 -85
  167. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +0 -171
  168. package/src/lib/data/__tests__/consistency.test.ts +0 -38
  169. package/src/lib/data/consistency.ts +0 -17
  170. package/src/lib/db/__tests__/entityMetadata.test.ts +0 -38
  171. package/src/lib/db/__tests__/pg-errors.test.ts +0 -48
  172. package/src/lib/db/entityMetadata.ts +0 -43
  173. package/src/lib/di/__tests__/container-app-di-absent.test.ts +0 -155
  174. package/src/lib/di/__tests__/container-app-di.test.ts +0 -173
  175. package/src/lib/di/__tests__/registrar-error-log.test.ts +0 -154
  176. package/src/lib/i18n/__tests__/config.test.ts +0 -8
  177. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +0 -30
  178. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +0 -29
  179. package/src/lib/logger/extension.ts +0 -59
  180. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +0 -122
  181. package/src/lib/modules/surfaceFingerprint.ts +0 -87
  182. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +0 -178
  183. package/src/lib/query/ciphertext-search-warning.ts +0 -95
  184. package/src/lib/search/__tests__/availability.test.ts +0 -211
  185. package/src/lib/search/__tests__/tokenLookup.test.ts +0 -206
  186. package/src/lib/search/__tests__/tokenize.test.ts +0 -49
  187. package/src/lib/search/auto-indexing.ts +0 -22
  188. package/src/lib/search/availability.ts +0 -232
  189. package/src/lib/search/tokenLookup.ts +0 -133
  190. package/src/lib/telemetry/runtime.ts +0 -72
  191. package/src/lib/webhooks/__tests__/body.test.ts +0 -95
  192. package/src/lib/webhooks/body.ts +0 -70
  193. package/src/lib/webhooks/inbound-types.ts +0 -125
  194. package/src/modules/__tests__/nav-group-order-override.test.ts +0 -183
  195. package/src/modules/payment_gateways/__tests__/types.test.ts +0 -37
  196. package/src/modules/widgets/__tests__/extension-points.test.ts +0 -101
  197. package/src/modules/widgets/extension-points.ts +0 -524
  198. package/src/security/__tests__/featurePolicy.test.ts +0 -166
  199. package/src/security/featurePolicy.ts +0 -89
@@ -1,232 +0,0 @@
1
- import { sql } from 'kysely'
2
- import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
3
-
4
- export type OrganizationScope = { ids: string[]; includeNull: boolean }
5
-
6
- export type SearchTokenSourceRef = { entity: string; recordIdColumn?: string }
7
-
8
- type ProbeExpression = object | string | readonly string[] | null
9
-
10
- export type SearchTokenProbeQueryBuilder = {
11
- select: (selection: ProbeExpression) => SearchTokenProbeQueryBuilder
12
- where: (column: ProbeExpression, operator?: string, value?: ProbeExpression) => SearchTokenProbeQueryBuilder
13
- limit: (count: number) => SearchTokenProbeQueryBuilder
14
- executeTakeFirst: () => Promise<object | undefined>
15
- }
16
-
17
- export type SearchTokenProbeDb = { selectFrom: (table: string) => SearchTokenProbeQueryBuilder }
18
-
19
- export type SearchTokenAvailabilityDebugPayload = {
20
- entity: string
21
- tenantId: string | null
22
- organizationScope?: OrganizationScope | null
23
- recordIdColumn?: string
24
- hasTokens?: boolean
25
- error?: string
26
- }
27
-
28
- export type SearchTokenAvailabilityDeps = {
29
- getDb: () => SearchTokenProbeDb
30
- getConfig: () => { enabled: boolean }
31
- applyOrganizationScope: (
32
- query: SearchTokenProbeQueryBuilder,
33
- column: string,
34
- scope: OrganizationScope,
35
- ) => SearchTokenProbeQueryBuilder
36
- logDebug: (event: string, payload: SearchTokenAvailabilityDebugPayload) => void
37
- }
38
-
39
- export type SearchTokenAvailability = {
40
- /**
41
- * The cheap, statically-known half of the decision: search is configured on
42
- * AND the `search_tokens` table exists. Safe to resolve eagerly (consumers
43
- * like custom-field source attachment need it before any filter is
44
- * inspected); the table probe is memoized per instance.
45
- */
46
- staticEnabled: () => Promise<boolean>
47
- /**
48
- * The expensive half: does `search_tokens` hold any row for this
49
- * (entity, tenant, organization scope)? Historically the `LIMIT 1` probe
50
- * could degenerate into a seq scan on a large table (#4723), so callers MUST
51
- * only ask when the query actually carries a like/ilike filter — use
52
- * `hasSearchFilter` for the gate. Three layers keep it cheap for the end
53
- * user: index-usable predicates (no `IS NOT DISTINCT FROM`) served by the
54
- * dedicated `search_tokens_presence_idx (entity_type, tenant_id,
55
- * organization_id)` prefix — which makes the MISS as cheap as the hit — a
56
- * per-request instance memo, and a process-level TTL cache
57
- * (`OM_SEARCH_TOKEN_PRESENCE_CACHE_MS`, default 30s) that amortizes the
58
- * probe across requests. Probe errors log `search:has-tokens-error`,
59
- * resolve to `false`, and are never TTL-cached.
60
- */
61
- hasTokens: (entity: string, tenantId: string | null, orgScope?: OrganizationScope | null) => Promise<boolean>
62
- /** First-hit sweep over token sources; logs `search:source-has-tokens` per probed source. */
63
- anySourceHasTokens: (
64
- sources: SearchTokenSourceRef[],
65
- tenantId: string | null,
66
- orgScope?: OrganizationScope | null,
67
- ) => Promise<boolean>
68
- }
69
-
70
- export function isSearchFilterOp(op: string | null | undefined): boolean {
71
- return op === 'like' || op === 'ilike'
72
- }
73
-
74
- /**
75
- * The single definition of "this query actually searches". Every consumer of
76
- * the token-availability answer sits behind a like/ilike guard, so when this
77
- * returns `false` the `hasTokens` probe's answer would never be read — gate
78
- * the probe on it.
79
- */
80
- export function hasSearchFilter(filters: ReadonlyArray<{ op?: string | null }>): boolean {
81
- return filters.some((filter) => isSearchFilterOp(filter.op))
82
- }
83
-
84
- function orgScopeKey(scope: OrganizationScope | null | undefined): string {
85
- if (!scope) return 'none'
86
- return `${scope.includeNull ? '1' : '0'}:${[...scope.ids].sort((left, right) => left.localeCompare(right)).join(',')}`
87
- }
88
-
89
- const PRESENCE_CACHE_DEFAULT_TTL_MS = 30_000
90
- const PRESENCE_CACHE_MAX_ENTRIES = 10_000
91
-
92
- // Process-level TTL cache for the token-presence answer. Module-level (process-global) on
93
- // purpose: `createRequestContainer` builds fresh engines — and with them fresh resolver
94
- // instances — per request, so an instance-scoped memo alone re-pays the probe on every
95
- // request. On a large `search_tokens` one probe can be pathologically expensive (#4723),
96
- // so the answer is amortized across requests here and only re-checked once per TTL.
97
- // Staleness contract: a stale `false` keeps like/ilike on the plain-column fallback for up
98
- // to the TTL after an entity's first tokens are written; a stale `true` routes search
99
- // through an emptied token set for up to the TTL after a purge. Both converge within the
100
- // TTL; set OM_SEARCH_TOKEN_PRESENCE_CACHE_MS=0 to disable and probe per request again.
101
- const presenceCache = new Map<string, { value: boolean; expiresAt: number }>()
102
-
103
- function resolvePresenceCacheTtlMs(): number {
104
- return parseNumberWithDefault(process.env.OM_SEARCH_TOKEN_PRESENCE_CACHE_MS, PRESENCE_CACHE_DEFAULT_TTL_MS, { integer: true, min: 0 })
105
- }
106
-
107
- function storePresence(key: string, value: boolean, ttlMs: number): void {
108
- if (presenceCache.size >= PRESENCE_CACHE_MAX_ENTRIES) {
109
- const now = Date.now()
110
- for (const [entryKey, entry] of presenceCache) {
111
- if (entry.expiresAt <= now) presenceCache.delete(entryKey)
112
- }
113
- if (presenceCache.size >= PRESENCE_CACHE_MAX_ENTRIES) presenceCache.clear()
114
- }
115
- presenceCache.set(key, { value, expiresAt: Date.now() + ttlMs })
116
- }
117
-
118
- export function clearSearchTokenPresenceCache(): void {
119
- presenceCache.clear()
120
- }
121
-
122
- /**
123
- * One place that answers "is token search usable here?" for both query
124
- * engines, instead of each hand-assembling config + table-existence +
125
- * token-presence probes with private duplicate helpers and ad-hoc memo maps.
126
- *
127
- * Memoization is per instance; engines are constructed per request
128
- * (`createRequestContainer`), so entries never outlive a request — the same
129
- * staleness contract the engines' previous per-query join maps had. A
130
- * rejected table probe is evicted so the next call retries instead of
131
- * observing a poisoned cache entry.
132
- */
133
- export function createSearchTokenAvailability(deps: SearchTokenAvailabilityDeps): SearchTokenAvailability {
134
- const tablePresence = new Map<string, Promise<boolean>>()
135
- const tokenPresence = new Map<string, Promise<boolean>>()
136
-
137
- const tableExists = (table: string): Promise<boolean> => {
138
- const cached = tablePresence.get(table)
139
- if (cached) return cached
140
- const probe = (async () => {
141
- const row = await deps.getDb()
142
- .selectFrom('information_schema.tables')
143
- .select(sql<number>`1`.as('one'))
144
- .where('table_name', '=', table)
145
- .limit(1)
146
- .executeTakeFirst()
147
- return !!row
148
- })()
149
- tablePresence.set(table, probe)
150
- probe.catch(() => tablePresence.delete(table))
151
- return probe
152
- }
153
-
154
- const probeTokens = async (
155
- entity: string,
156
- tenantId: string | null,
157
- orgScope?: OrganizationScope | null,
158
- ): Promise<boolean> => {
159
- let query = deps.getDb()
160
- .selectFrom('search_tokens')
161
- .select(sql<number>`1`.as('one'))
162
- .where('entity_type', '=', entity)
163
- // Deliberately `= / IS NULL` instead of `IS NOT DISTINCT FROM` (identical semantics
164
- // for a string|null tenant): the latter cannot serve as an index condition, which is
165
- // part of why the planner degraded this probe to a seq scan on large tables (#4723).
166
- // With plain predicates the probe is a pure prefix seek on
167
- // `search_tokens_presence_idx (entity_type, tenant_id, organization_id)`, making the
168
- // miss as cheap as the hit.
169
- query = tenantId == null
170
- ? query.where('tenant_id', 'is', null)
171
- : query.where('tenant_id', '=', tenantId)
172
- if (orgScope) {
173
- query = deps.applyOrganizationScope(query, 'search_tokens.organization_id', orgScope)
174
- }
175
- const row = await query.limit(1).executeTakeFirst()
176
- return !!row
177
- }
178
-
179
- const hasTokens = (
180
- entity: string,
181
- tenantId: string | null,
182
- orgScope?: OrganizationScope | null,
183
- ): Promise<boolean> => {
184
- const key = `${entity}|${tenantId ?? '__null__'}|${orgScopeKey(orgScope)}`
185
- const ttlMs = resolvePresenceCacheTtlMs()
186
- if (ttlMs > 0) {
187
- const entry = presenceCache.get(key)
188
- if (entry && entry.expiresAt > Date.now()) return Promise.resolve(entry.value)
189
- }
190
- const cached = tokenPresence.get(key)
191
- if (cached) return cached
192
- const probe = (async () => {
193
- try {
194
- const value = await probeTokens(entity, tenantId, orgScope)
195
- // Only genuine probe results enter the process-level cache — caching an
196
- // error-driven `false` would pin degraded search for a full TTL after a
197
- // transient DB failure.
198
- if (ttlMs > 0) storePresence(key, value, ttlMs)
199
- return value
200
- } catch (err) {
201
- deps.logDebug('search:has-tokens-error', {
202
- entity,
203
- tenantId,
204
- organizationScope: orgScope,
205
- error: err instanceof Error ? err.message : String(err),
206
- })
207
- return false
208
- }
209
- })()
210
- tokenPresence.set(key, probe)
211
- return probe
212
- }
213
-
214
- return {
215
- staticEnabled: async () => deps.getConfig().enabled && await tableExists('search_tokens'),
216
- hasTokens,
217
- anySourceHasTokens: async (sources, tenantId, orgScope) => {
218
- for (const source of sources) {
219
- const ok = await hasTokens(source.entity, tenantId, orgScope)
220
- deps.logDebug('search:source-has-tokens', {
221
- entity: source.entity,
222
- recordIdColumn: source.recordIdColumn,
223
- tenantId,
224
- organizationScope: orgScope,
225
- hasTokens: ok,
226
- })
227
- if (ok) return true
228
- }
229
- return false
230
- },
231
- }
232
- }
@@ -1,133 +0,0 @@
1
- import { type Kysely, sql } from 'kysely'
2
- import { resolveSearchConfig, type SearchConfig } from './config'
3
- import { tokenizeText } from './tokenize'
4
-
5
- export type SearchTokenDatabase = {
6
- search_tokens: {
7
- entity_id: string
8
- entity_type: string
9
- field: string
10
- token_hash: string
11
- tenant_id: string | null
12
- organization_id: string | null
13
- }
14
- }
15
-
16
- /**
17
- * Tenant/organization scoping for a `search_tokens` lookup.
18
- *
19
- * `undefined` and `null` are NOT interchangeable:
20
- * - `undefined` omits the predicate entirely (the caller owns visibility).
21
- * - `null` emits a null-safe predicate that matches only globally scoped rows.
22
- */
23
- export type SearchTokenScope = {
24
- tenantId?: string | null
25
- organizationId?: string | null
26
- organizationIds?: readonly string[] | null
27
- }
28
-
29
- /**
30
- * Why a lookup could not produce an id set. Callers MUST NOT read these as
31
- * "nothing matched" — the token index was never consulted, so the caller's own
32
- * predicate (usually an `ilike`) is still the authoritative one.
33
- */
34
- export type SearchTokenLookupSkipReason = 'empty-query' | 'search-disabled' | 'no-tokens'
35
-
36
- export type SearchTokenLookupResult =
37
- | { matched: true; ids: string[] }
38
- | { matched: false; reason: SearchTokenLookupSkipReason }
39
-
40
- export type FindEntityIdsBySearchTokensInput = {
41
- db: Kysely<SearchTokenDatabase>
42
- entityType: string
43
- query: string
44
- fields?: readonly string[] | null
45
- scope?: SearchTokenScope
46
- config?: SearchConfig
47
- }
48
-
49
- /**
50
- * Resolve the record ids whose indexed `search_tokens` cover every token in
51
- * `query`.
52
- *
53
- * This is the encryption-safe replacement for `ilike` filtering on columns an
54
- * encryption map covers: the stored column holds ciphertext, so
55
- * `ilike '%term%'` silently matches nothing, while the token index stores
56
- * hashes of the plaintext and keeps matching. See issue #2990.
57
- *
58
- * Matching requires ALL query tokens to be present on the record. The token
59
- * search strategy in `@open-mercato/search` uses a looser match ratio; list
60
- * endpoints want the stricter behavior so a two-word query narrows rather than
61
- * widens the result set.
62
- */
63
- export async function findEntityIdsBySearchTokens({
64
- db,
65
- entityType,
66
- query,
67
- fields,
68
- scope,
69
- config,
70
- }: FindEntityIdsBySearchTokensInput): Promise<SearchTokenLookupResult> {
71
- const trimmed = query.trim()
72
- if (!trimmed) return { matched: false, reason: 'empty-query' }
73
-
74
- const searchConfig = config ?? resolveSearchConfig()
75
- if (!searchConfig.enabled) return { matched: false, reason: 'search-disabled' }
76
-
77
- const { hashes } = tokenizeText(trimmed, searchConfig)
78
- if (!hashes.length) return { matched: false, reason: 'no-tokens' }
79
-
80
- let builder = db
81
- .selectFrom('search_tokens')
82
- .select('entity_id')
83
- .where('entity_type', '=', entityType)
84
- .where('token_hash', 'in', hashes)
85
-
86
- const scopedFields = (fields ?? []).filter((field) => typeof field === 'string' && field.length > 0)
87
- if (scopedFields.length === 1) {
88
- builder = builder.where('field', '=', scopedFields[0])
89
- } else if (scopedFields.length > 1) {
90
- builder = builder.where('field', 'in', Array.from(scopedFields))
91
- }
92
-
93
- if (scope?.tenantId !== undefined) {
94
- builder = builder.where(sql<boolean>`tenant_id is not distinct from ${scope.tenantId}`)
95
- }
96
-
97
- if (scope?.organizationId !== undefined) {
98
- builder = scope.organizationId === null
99
- ? builder.where(sql<boolean>`organization_id is not distinct from ${null}`)
100
- : builder.where('organization_id', '=', scope.organizationId)
101
- } else if (scope?.organizationIds?.length) {
102
- builder = builder.where('organization_id', 'in', Array.from(scope.organizationIds))
103
- }
104
-
105
- const rows = (await builder
106
- .groupBy('entity_id')
107
- .having(sql<boolean>`count(distinct token_hash) >= ${hashes.length}`)
108
- .execute()) as Array<{ entity_id?: unknown }>
109
-
110
- const ids = rows
111
- .map((row) => (typeof row.entity_id === 'string' ? row.entity_id : null))
112
- .filter((id): id is string => typeof id === 'string' && id.length > 0)
113
-
114
- return { matched: true, ids }
115
- }
116
-
117
- /**
118
- * Legacy-shaped adapter for call sites that predate
119
- * {@link SearchTokenLookupResult}: `null` for a blank query, `[]` for every
120
- * other non-answer, otherwise the matched ids.
121
- *
122
- * Prefer {@link findEntityIdsBySearchTokens} in new code — the discriminated
123
- * result distinguishes "the index says nothing matched" from "the index was
124
- * never consulted", and that distinction is exactly what a `null`/`[]` pair
125
- * loses.
126
- */
127
- export async function findEntityIdsBySearchTokensCompat(
128
- input: FindEntityIdsBySearchTokensInput,
129
- ): Promise<string[] | null> {
130
- const result = await findEntityIdsBySearchTokens(input)
131
- if (result.matched) return result.ids
132
- return result.reason === 'empty-query' ? null : []
133
- }
@@ -1,72 +0,0 @@
1
- export type TelemetryTraceCarrier = Record<string, string>
2
-
3
- export type TelemetryRuntime = {
4
- /**
5
- * True only when the active SDK may safely use the process-global W3C
6
- * propagator for cross-boundary extraction.
7
- */
8
- canUseGlobalTracePropagation(): boolean
9
- captureTraceContext(): TelemetryTraceCarrier
10
- continueTrace<T>(
11
- carrier: TelemetryTraceCarrier | undefined,
12
- name: string,
13
- fn: () => T,
14
- options?: { kind?: 'internal' | 'server' | 'client' | 'producer' | 'consumer' },
15
- ): T
16
- recordHttpDuration(method: string, route: string, status: number, startedAt: number): void
17
- reportError(
18
- error: unknown,
19
- context?: {
20
- module?: string
21
- attributes?: Record<string, string | number | boolean | undefined>
22
- },
23
- ): void
24
- shutdown(): Promise<void>
25
- }
26
-
27
- const GLOBAL_KEY = Symbol.for('@open-mercato/shared.telemetryRuntime')
28
- const ENABLED_BACKENDS = new Set(['console', 'signoz', 'newrelic', 'otlp'])
29
-
30
- type TelemetryRuntimeStore = {
31
- active?: TelemetryRuntime
32
- }
33
-
34
- function store(): TelemetryRuntimeStore {
35
- const globalStore = globalThis as unknown as Record<symbol, TelemetryRuntimeStore | undefined>
36
- let current = globalStore[GLOBAL_KEY]
37
- if (!current) {
38
- current = {}
39
- globalStore[GLOBAL_KEY] = current
40
- }
41
- return current
42
- }
43
-
44
- /**
45
- * This check is intentionally owned by shared code so hosts can decide whether
46
- * to dynamically import the telemetry package without evaluating that package.
47
- */
48
- export function isTelemetryBackendEnabled(raw?: string): boolean {
49
- const value = raw ?? (
50
- typeof process === 'undefined'
51
- ? undefined
52
- : process.env.TELEMETRY_BACKEND
53
- )
54
- return ENABLED_BACKENDS.has((value ?? '').trim().toLowerCase())
55
- }
56
-
57
- export function registerTelemetryRuntime(runtime: TelemetryRuntime): () => void {
58
- store().active = runtime
59
- return () => {
60
- const current = store()
61
- if (current.active === runtime) current.active = undefined
62
- }
63
- }
64
-
65
- export function getTelemetryRuntime(): TelemetryRuntime | undefined {
66
- return store().active
67
- }
68
-
69
- /** Test-only: clear the process-wide telemetry bridge. */
70
- export function resetTelemetryRuntime(): void {
71
- store().active = undefined
72
- }
@@ -1,95 +0,0 @@
1
- /** @jest-environment node */
2
- import {
3
- DEFAULT_WEBHOOK_BODY_LIMIT_BYTES,
4
- readBoundedRequestBody,
5
- resolveWebhookBodyLimitBytes,
6
- WebhookBodyTooLargeError,
7
- } from '../body'
8
-
9
- function makeStreamingRequest(chunks: Uint8Array[], headers?: HeadersInit) {
10
- let index = 0
11
- const cancel = jest.fn()
12
- const body = new ReadableStream<Uint8Array>({
13
- pull(controller) {
14
- const chunk = chunks[index]
15
- index += 1
16
- if (chunk) controller.enqueue(chunk)
17
- else controller.close()
18
- },
19
- cancel,
20
- })
21
- const request = new Request('http://localhost/webhook', {
22
- method: 'POST',
23
- headers,
24
- body,
25
- duplex: 'half',
26
- } as RequestInit & { duplex: 'half' })
27
- return { request, cancel }
28
- }
29
-
30
- describe('readBoundedRequestBody', () => {
31
- const encoder = new TextEncoder()
32
-
33
- it('rejects an oversized declared length before reading the body', async () => {
34
- const request = new Request('http://localhost/webhook', {
35
- method: 'POST',
36
- headers: { 'content-length': '6' },
37
- body: 'ok',
38
- })
39
-
40
- await expect(readBoundedRequestBody(request, { maxBytes: 5 })).rejects.toEqual(
41
- expect.objectContaining<WebhookBodyTooLargeError>({ limitBytes: 5 }),
42
- )
43
- })
44
-
45
- it('stops a chunked body when its actual byte count crosses the limit', async () => {
46
- const { request, cancel } = makeStreamingRequest([
47
- encoder.encode('abc'),
48
- encoder.encode('def'),
49
- encoder.encode('never-read'),
50
- ])
51
-
52
- await expect(readBoundedRequestBody(request, { maxBytes: 5 })).rejects.toBeInstanceOf(
53
- WebhookBodyTooLargeError,
54
- )
55
- expect(cancel).toHaveBeenCalled()
56
- })
57
-
58
- it.each([
59
- ['invalid', 'not-a-number'],
60
- ['lying', '3'],
61
- ])('does not let a %s Content-Length bypass the streamed cap', async (_case, contentLength) => {
62
- const { request } = makeStreamingRequest(
63
- [encoder.encode('abc'), encoder.encode('def')],
64
- { 'content-length': contentLength },
65
- )
66
-
67
- await expect(readBoundedRequestBody(request, { maxBytes: 5 })).rejects.toBeInstanceOf(
68
- WebhookBodyTooLargeError,
69
- )
70
- })
71
-
72
- it('preserves the exact decoded body at the byte boundary', async () => {
73
- const bytes = encoder.encode('a€')
74
- const { request } = makeStreamingRequest([bytes.slice(0, 2), bytes.slice(2)])
75
-
76
- await expect(readBoundedRequestBody(request, { maxBytes: bytes.byteLength })).resolves.toBe('a€')
77
- })
78
- })
79
-
80
- describe('resolveWebhookBodyLimitBytes', () => {
81
- it('uses the documented default for missing or invalid configuration', () => {
82
- expect(resolveWebhookBodyLimitBytes(undefined)).toBe(DEFAULT_WEBHOOK_BODY_LIMIT_BYTES)
83
- expect(resolveWebhookBodyLimitBytes('invalid')).toBe(DEFAULT_WEBHOOK_BODY_LIMIT_BYTES)
84
- expect(resolveWebhookBodyLimitBytes('0')).toBe(DEFAULT_WEBHOOK_BODY_LIMIT_BYTES)
85
- })
86
-
87
- it('accepts a positive safe integer override', () => {
88
- expect(resolveWebhookBodyLimitBytes('2097152')).toBe(2 * 1024 * 1024)
89
- })
90
-
91
- it('supports a source-specific fallback without weakening validation', () => {
92
- expect(resolveWebhookBodyLimitBytes(undefined, 2 * 1024 * 1024)).toBe(2 * 1024 * 1024)
93
- expect(resolveWebhookBodyLimitBytes('invalid', 2 * 1024 * 1024)).toBe(2 * 1024 * 1024)
94
- })
95
- })
@@ -1,70 +0,0 @@
1
- import { parseNumberWithDefault } from '../number'
2
-
3
- export const DEFAULT_WEBHOOK_BODY_LIMIT_BYTES = 1024 * 1024
4
-
5
- export class WebhookBodyTooLargeError extends Error {
6
- readonly limitBytes: number
7
-
8
- constructor(limitBytes: number) {
9
- super(`Webhook body exceeds the ${limitBytes}-byte limit`)
10
- this.name = 'WebhookBodyTooLargeError'
11
- this.limitBytes = limitBytes
12
- }
13
- }
14
-
15
- export function resolveWebhookBodyLimitBytes(
16
- raw = typeof process === 'undefined' ? undefined : process.env.OM_WEBHOOK_MAX_BODY_BYTES,
17
- fallbackBytes = DEFAULT_WEBHOOK_BODY_LIMIT_BYTES,
18
- ): number {
19
- const fallback = Number.isSafeInteger(fallbackBytes) && fallbackBytes > 0
20
- ? fallbackBytes
21
- : DEFAULT_WEBHOOK_BODY_LIMIT_BYTES
22
- const parsed = parseNumberWithDefault(raw, fallback, { min: 1 })
23
- return Number.isSafeInteger(parsed) ? parsed : fallback
24
- }
25
-
26
- export async function readBoundedRequestBody(
27
- request: Request,
28
- options?: { maxBytes?: number },
29
- ): Promise<string> {
30
- const configuredLimit = options?.maxBytes ?? resolveWebhookBodyLimitBytes()
31
- const maxBytes = Number.isSafeInteger(configuredLimit) && configuredLimit > 0
32
- ? configuredLimit
33
- : DEFAULT_WEBHOOK_BODY_LIMIT_BYTES
34
- const declaredLength = request.headers.get('content-length')?.trim()
35
- if (declaredLength && /^\d+$/.test(declaredLength)) {
36
- const declaredBytes = Number(declaredLength)
37
- if (!Number.isSafeInteger(declaredBytes) || declaredBytes > maxBytes) {
38
- throw new WebhookBodyTooLargeError(maxBytes)
39
- }
40
- }
41
-
42
- if (!request.body) return ''
43
-
44
- const reader = request.body.getReader()
45
- const chunks: Uint8Array[] = []
46
- let totalBytes = 0
47
-
48
- try {
49
- while (true) {
50
- const { done, value } = await reader.read()
51
- if (done) break
52
- totalBytes += value.byteLength
53
- if (totalBytes > maxBytes) {
54
- await reader.cancel().catch(() => undefined)
55
- throw new WebhookBodyTooLargeError(maxBytes)
56
- }
57
- chunks.push(value)
58
- }
59
- } finally {
60
- reader.releaseLock()
61
- }
62
-
63
- const body = new Uint8Array(totalBytes)
64
- let offset = 0
65
- for (const chunk of chunks) {
66
- body.set(chunk, offset)
67
- offset += chunk.byteLength
68
- }
69
- return new TextDecoder().decode(body)
70
- }