@open-mercato/shared 0.10.1-develop.7576.1.21d5b50435 → 0.10.1-develop.7577.1.477f2486e5

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 (125) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -4
  3. package/dist/lib/auth/server.js +1 -1
  4. package/dist/lib/auth/server.js.map +2 -2
  5. package/dist/lib/bootstrap/dynamicLoader.js +46 -6
  6. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  7. package/dist/lib/bootstrap/factory.js +4 -3
  8. package/dist/lib/bootstrap/factory.js.map +2 -2
  9. package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js +6 -0
  10. package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js.map +7 -0
  11. package/dist/lib/bootstrap/next-subpath-resolve-hook.js +62 -0
  12. package/dist/lib/bootstrap/next-subpath-resolve-hook.js.map +7 -0
  13. package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js +33 -0
  14. package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js.map +7 -0
  15. package/dist/lib/catalog-visibility/hashEffectiveScope.js +9 -0
  16. package/dist/lib/catalog-visibility/hashEffectiveScope.js.map +7 -0
  17. package/dist/lib/catalog-visibility/index.js +4 -0
  18. package/dist/lib/catalog-visibility/index.js.map +2 -2
  19. package/dist/lib/commands/command-bus.js +45 -13
  20. package/dist/lib/commands/command-bus.js.map +2 -2
  21. package/dist/lib/commands/types.js.map +2 -2
  22. package/dist/lib/crud/exporters.js +1 -1
  23. package/dist/lib/crud/exporters.js.map +2 -2
  24. package/dist/lib/crud/ids.js +13 -1
  25. package/dist/lib/crud/ids.js.map +2 -2
  26. package/dist/lib/db/advisoryLock.js +172 -0
  27. package/dist/lib/db/advisoryLock.js.map +7 -0
  28. package/dist/lib/db/pg-errors.js +14 -0
  29. package/dist/lib/db/pg-errors.js.map +2 -2
  30. package/dist/lib/localization/index.js +9 -1
  31. package/dist/lib/localization/index.js.map +2 -2
  32. package/dist/lib/localization/translatable-fields.js +26 -0
  33. package/dist/lib/localization/translatable-fields.js.map +2 -2
  34. package/dist/lib/number.js +6 -0
  35. package/dist/lib/number.js.map +2 -2
  36. package/dist/lib/openapi/generator.js +84 -67
  37. package/dist/lib/openapi/generator.js.map +2 -2
  38. package/dist/lib/query/engine.js +45 -10
  39. package/dist/lib/query/engine.js.map +2 -2
  40. package/dist/lib/query/join-utils.js +1 -1
  41. package/dist/lib/query/join-utils.js.map +2 -2
  42. package/dist/lib/query/overlap.js +58 -0
  43. package/dist/lib/query/overlap.js.map +7 -0
  44. package/dist/lib/query/types.js.map +2 -2
  45. package/dist/lib/url.js +24 -0
  46. package/dist/lib/url.js.map +2 -2
  47. package/dist/lib/version.js +1 -1
  48. package/dist/lib/version.js.map +1 -1
  49. package/dist/lib/webhooks/body.js +7 -3
  50. package/dist/lib/webhooks/body.js.map +2 -2
  51. package/dist/lib/webhooks/index.js +2 -0
  52. package/dist/lib/webhooks/index.js.map +2 -2
  53. package/dist/modules/integrations/types.js.map +2 -2
  54. package/dist/modules/notifications/safeHref.js +20 -0
  55. package/dist/modules/notifications/safeHref.js.map +7 -0
  56. package/dist/modules/notifications/types.js +4 -0
  57. package/dist/modules/notifications/types.js.map +3 -3
  58. package/dist/modules/payment_gateways/types.js +14 -1
  59. package/dist/modules/payment_gateways/types.js.map +2 -2
  60. package/dist/modules/registry.js.map +2 -2
  61. package/dist/modules/search.js.map +2 -2
  62. package/dist/modules/widgets/injection-loader.js +14 -3
  63. package/dist/modules/widgets/injection-loader.js.map +2 -2
  64. package/package.json +9 -2
  65. package/src/lib/__tests__/number.test.ts +24 -1
  66. package/src/lib/__tests__/url.cross-site.test.ts +140 -0
  67. package/src/lib/auth/__tests__/server.test.ts +14 -0
  68. package/src/lib/auth/server.ts +1 -1
  69. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +2 -2
  70. package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +2 -2
  71. package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +2 -2
  72. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +2 -2
  73. package/src/lib/bootstrap/__tests__/dynamicLoader.externalSubpath.test.ts +141 -0
  74. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +2 -2
  75. package/src/lib/bootstrap/__tests__/factory.test.ts +18 -6
  76. package/src/lib/bootstrap/__tests__/next-subpath-resolve-hook.test.ts +174 -0
  77. package/src/lib/bootstrap/dynamicLoader.ts +63 -5
  78. package/src/lib/bootstrap/factory.ts +8 -3
  79. package/src/lib/bootstrap/next-subpath-resolve-hook-worker.ts +3 -0
  80. package/src/lib/bootstrap/next-subpath-resolve-hook.ts +130 -0
  81. package/src/lib/bootstrap/types.ts +5 -1
  82. package/src/lib/catalog-visibility/__tests__/hashEffectiveScope.test.ts +121 -0
  83. package/src/lib/catalog-visibility/canonicalizeEffectiveScope.ts +41 -0
  84. package/src/lib/catalog-visibility/hashEffectiveScope.ts +8 -0
  85. package/src/lib/catalog-visibility/index.ts +2 -0
  86. package/src/lib/commands/__tests__/command-bus.test.ts +100 -1
  87. package/src/lib/commands/command-bus.ts +42 -10
  88. package/src/lib/commands/command-interceptor.ts +4 -0
  89. package/src/lib/commands/types.ts +14 -0
  90. package/src/lib/crud/__tests__/exporters.test.ts +18 -0
  91. package/src/lib/crud/__tests__/ids.test.ts +34 -1
  92. package/src/lib/crud/exporters.ts +1 -1
  93. package/src/lib/crud/ids.ts +24 -0
  94. package/src/lib/db/__tests__/advisoryLock.integration.test.ts +343 -0
  95. package/src/lib/db/__tests__/advisoryLock.test.ts +564 -0
  96. package/src/lib/db/__tests__/pg-errors.test.ts +68 -1
  97. package/src/lib/db/advisoryLock.ts +271 -0
  98. package/src/lib/db/pg-errors.ts +35 -0
  99. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +23 -0
  100. package/src/lib/localization/__tests__/translatable-fields.test.ts +17 -0
  101. package/src/lib/localization/index.ts +8 -1
  102. package/src/lib/localization/translatable-fields.ts +46 -0
  103. package/src/lib/number.ts +13 -0
  104. package/src/lib/openapi/__tests__/optional-path-params.test.ts +102 -0
  105. package/src/lib/openapi/generator.ts +106 -77
  106. package/src/lib/query/__tests__/empty-ids-filter.pg.test.ts +89 -0
  107. package/src/lib/query/__tests__/engine.empty-in-filter.test.ts +79 -0
  108. package/src/lib/query/__tests__/overlap.test.ts +219 -0
  109. package/src/lib/query/engine.ts +58 -8
  110. package/src/lib/query/join-utils.ts +1 -1
  111. package/src/lib/query/overlap.ts +106 -0
  112. package/src/lib/query/types.ts +13 -1
  113. package/src/lib/url.ts +30 -0
  114. package/src/lib/webhooks/__tests__/body.test.ts +48 -0
  115. package/src/lib/webhooks/body.ts +11 -4
  116. package/src/lib/webhooks/index.ts +1 -0
  117. package/src/modules/integrations/types.ts +6 -0
  118. package/src/modules/notifications/safeHref.ts +19 -0
  119. package/src/modules/notifications/types.ts +7 -0
  120. package/src/modules/payment_gateways/__tests__/types.test.ts +44 -0
  121. package/src/modules/payment_gateways/types.ts +52 -6
  122. package/src/modules/registry.ts +5 -0
  123. package/src/modules/search.ts +39 -0
  124. package/src/modules/widgets/__tests__/injection-loader.partitioned-registration.test.ts +78 -0
  125. package/src/modules/widgets/injection-loader.ts +33 -3
@@ -15,6 +15,12 @@ type PathParamInfo = {
15
15
  optional?: boolean
16
16
  }
17
17
 
18
+ type PathVariant = {
19
+ path: string
20
+ params: PathParamInfo[]
21
+ omittedParam?: string
22
+ }
23
+
18
24
  type ParameterLocation = 'query' | 'path' | 'header'
19
25
 
20
26
  export type JsonSchema = Record<string, unknown>
@@ -119,6 +125,20 @@ function normalizePath(path: string): { path: string; params: PathParamInfo[] }
119
125
  return { path: '/' + normalized, params }
120
126
  }
121
127
 
128
+ function expandOptionalPathParams(normalized: { path: string; params: PathParamInfo[] }): PathVariant[] {
129
+ const required = normalized.params.filter((param) => !param.optional)
130
+ const optionalParam = normalized.params.find((param) => param.optional)
131
+ const fullVariant: PathVariant = {
132
+ path: normalized.path,
133
+ params: normalized.params.map((param) => ({ ...param, optional: false })),
134
+ }
135
+ if (!optionalParam) return [fullVariant]
136
+ const placeholder = `/{${optionalParam.name}}`
137
+ if (!normalized.path.endsWith(placeholder)) return [fullVariant]
138
+ const basePath = normalized.path.slice(0, -placeholder.length) || '/'
139
+ return [fullVariant, { path: basePath, params: required, omittedParam: optionalParam.name }]
140
+ }
141
+
122
142
  function unwrap(schema?: ZodTypeAny): {
123
143
  schema: ZodTypeAny | undefined
124
144
  optional: boolean
@@ -681,14 +701,14 @@ function buildParameters(
681
701
 
682
702
  if (location === 'path' && pathParamNames && pathParamNames.length) {
683
703
  const merged = mergePathParamSchemas(schema, pathParamNames)
684
- for (const { name, schema: paramSchema, optional } of merged) {
704
+ for (const { name, schema: paramSchema } of merged) {
685
705
  const jsonSchema = zodToJsonSchema(paramSchema)
686
706
  const example = generateExample(paramSchema)
687
707
  const description = extractZodDescription(paramSchema)
688
708
  params.push({
689
709
  name,
690
710
  in: 'path',
691
- required: !optional,
711
+ required: true,
692
712
  schema: jsonSchema ?? { type: 'string' },
693
713
  example,
694
714
  ...(description ? { description } : {}),
@@ -738,7 +758,7 @@ function buildParameters(
738
758
  }
739
759
 
740
760
  function mergePathParamSchemas(schema: ZodTypeAny | undefined, params: PathParamInfo[]) {
741
- const merged: Array<{ name: string; schema: ZodTypeAny | undefined; optional: boolean }> = []
761
+ const merged: Array<{ name: string; schema: ZodTypeAny | undefined }> = []
742
762
  const map: Record<string, ZodTypeAny> = {}
743
763
  if (schema) {
744
764
  const { schema: unwrapped } = unwrap(schema)
@@ -753,7 +773,6 @@ function mergePathParamSchemas(schema: ZodTypeAny | undefined, params: PathParam
753
773
  merged.push({
754
774
  name: param.name,
755
775
  schema: map[param.name],
756
- optional: !!param.optional,
757
776
  })
758
777
  }
759
778
  return merged
@@ -1051,6 +1070,7 @@ export function buildOpenApiDocument(modules: Module[], options: OpenApiDocument
1051
1070
 
1052
1071
  const includeAccessControlMetadata = options.includeAccessControlMetadata !== false
1053
1072
  const tags = new Map<string, string | undefined>()
1073
+ const derivedOperations = new Set<string>()
1054
1074
 
1055
1075
  for (const moduleEntry of modules) {
1056
1076
  const defaultTag = moduleEntry.info?.title ?? toTitle(moduleEntry.id)
@@ -1060,85 +1080,94 @@ export function buildOpenApiDocument(modules: Module[], options: OpenApiDocument
1060
1080
  for (const api of apis) {
1061
1081
  const routeDoc = collectRouteDoc(api, moduleEntry.id)
1062
1082
  const moduleTag = routeDoc?.tag ?? defaultTag
1063
- const normalized = normalizePath((api as any).path ?? (api as any).path ?? '')
1064
- const pathKey = normalized.path
1065
- if (!doc.paths[pathKey]) doc.paths[pathKey] = {}
1066
1083
  const availableMethods: HttpMethod[] =
1067
1084
  'handlers' in api
1068
1085
  ? HTTP_METHODS.filter((method) => typeof (api as ModuleApiRouteFile).handlers?.[method] === 'function')
1069
1086
  : [api.method as HttpMethod]
1070
1087
 
1071
- for (const method of availableMethods) {
1072
- const methodLower = method.toLowerCase() as Lowercase<HttpMethod>
1073
- const existing = doc.paths[pathKey][methodLower]
1074
- if (existing) continue
1075
-
1076
- const metadata = 'handlers' in api ? (api as ModuleApiRouteFile).metadata?.[method] : undefined
1077
- const methodDoc = routeDoc?.methods?.[method]
1078
- const summary = methodDoc?.summary ?? routeDoc?.summary ?? `${method} ${pathKey}`
1079
- const baseDescription = methodDoc?.description ?? routeDoc?.description
1080
- const meta = metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : undefined
1081
- const requireFeatures = Array.isArray(meta?.['requireFeatures'])
1082
- ? (meta!['requireFeatures'] as string[])
1083
- : undefined
1084
- const requireRoles = Array.isArray(meta?.['requireRoles'])
1085
- ? (meta!['requireRoles'] as string[])
1086
- : undefined
1087
- const requireAuth = meta?.['requireAuth'] === true
1088
- const descriptionParts: string[] = []
1089
- if (baseDescription) descriptionParts.push(baseDescription)
1090
- if (includeAccessControlMetadata && Array.isArray(requireFeatures) && requireFeatures.length) {
1091
- descriptionParts.push(`Requires features: ${requireFeatures.join(', ')}`)
1092
- }
1093
- if (includeAccessControlMetadata && Array.isArray(requireRoles) && requireRoles.length) {
1094
- descriptionParts.push(`Requires roles: ${requireRoles.join(', ')}`)
1095
- }
1088
+ for (const normalized of expandOptionalPathParams(normalizePath((api as any).path ?? ''))) {
1089
+ const pathKey = normalized.path
1090
+ if (!doc.paths[pathKey]) doc.paths[pathKey] = {}
1091
+
1092
+ for (const method of availableMethods) {
1093
+ const methodLower = method.toLowerCase() as Lowercase<HttpMethod>
1094
+ const operationKey = `${methodLower} ${pathKey}`
1095
+ const existing = doc.paths[pathKey][methodLower]
1096
+ if (existing && (normalized.omittedParam || !derivedOperations.has(operationKey))) continue
1097
+ if (normalized.omittedParam) derivedOperations.add(operationKey)
1098
+ else derivedOperations.delete(operationKey)
1099
+
1100
+ const metadata = 'handlers' in api ? (api as ModuleApiRouteFile).metadata?.[method] : undefined
1101
+ const methodDoc = routeDoc?.methods?.[method]
1102
+ const summary = methodDoc?.summary ?? routeDoc?.summary ?? `${method} ${pathKey}`
1103
+ const baseDescription = methodDoc?.description ?? routeDoc?.description
1104
+ const meta = metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : undefined
1105
+ const requireFeatures = Array.isArray(meta?.['requireFeatures'])
1106
+ ? (meta!['requireFeatures'] as string[])
1107
+ : undefined
1108
+ const requireRoles = Array.isArray(meta?.['requireRoles'])
1109
+ ? (meta!['requireRoles'] as string[])
1110
+ : undefined
1111
+ const requireAuth = meta?.['requireAuth'] === true
1112
+ const descriptionParts: string[] = []
1113
+ if (baseDescription) descriptionParts.push(baseDescription)
1114
+ if (includeAccessControlMetadata && Array.isArray(requireFeatures) && requireFeatures.length) {
1115
+ descriptionParts.push(`Requires features: ${requireFeatures.join(', ')}`)
1116
+ }
1117
+ if (includeAccessControlMetadata && Array.isArray(requireRoles) && requireRoles.length) {
1118
+ descriptionParts.push(`Requires roles: ${requireRoles.join(', ')}`)
1119
+ }
1096
1120
 
1097
- const querySchema = methodDoc?.query
1098
- const pathSchema = methodDoc?.pathParams ?? routeDoc?.pathParams
1099
- const headerSchema = methodDoc?.headers
1100
- const requestBody = methodDoc?.requestBody
1101
- const examples = collectExamples(querySchema, requestBody?.schema, pathSchema, headerSchema, metadata)
1102
- const curlSample = buildCurlSample(
1103
- method,
1104
- pathKey,
1105
- normalized.params,
1106
- examples,
1107
- options.baseUrlForExamples ?? 'https://api.open-mercato.local',
1108
- metadata,
1109
- requestBody
1110
- )
1111
-
1112
- doc.paths[pathKey][methodLower] = {
1113
- operationId: methodDoc?.operationId ?? resolveOperationId(moduleEntry.id, pathKey, method),
1114
- summary,
1115
- description: descriptionParts.length ? descriptionParts.join('\n\n') : undefined,
1116
- tags: methodDoc?.tags ?? (moduleTag ? [moduleTag] : undefined),
1117
- deprecated: methodDoc?.deprecated,
1118
- externalDocs: methodDoc?.externalDocs,
1119
- parameters: [
1120
- ...buildParameters(pathSchema, 'path', normalized.params),
1121
- ...buildParameters(querySchema, 'query'),
1122
- ...buildParameters(headerSchema, 'header'),
1123
- ].filter(Boolean),
1124
- requestBody: buildRequestBody(requestBody),
1125
- responses: buildResponses(method, methodDoc?.responses, methodDoc?.errors, metadata),
1126
- security: buildSecurity(metadata, methodDoc, options.defaultSecurity),
1127
- 'x-codeSamples': methodDoc?.codeSamples ?? [
1128
- {
1129
- lang: 'curl',
1130
- label: 'cURL',
1131
- source: curlSample,
1132
- },
1133
- ],
1134
- ...(includeAccessControlMetadata && Array.isArray(requireFeatures) && requireFeatures.length
1135
- ? { 'x-require-features': requireFeatures }
1136
- : {}),
1137
- ...(includeAccessControlMetadata && Array.isArray(requireRoles) && requireRoles.length
1138
- ? { 'x-require-roles': requireRoles }
1139
- : {}),
1140
- ...(requireAuth ? { 'x-require-auth': true } : {}),
1141
- ...(methodDoc?.extensions ?? {}),
1121
+ const querySchema = methodDoc?.query
1122
+ const pathSchema = methodDoc?.pathParams ?? routeDoc?.pathParams
1123
+ const headerSchema = methodDoc?.headers
1124
+ const requestBody = methodDoc?.requestBody
1125
+ const examples = collectExamples(querySchema, requestBody?.schema, pathSchema, headerSchema, metadata)
1126
+ const curlSample = buildCurlSample(
1127
+ method,
1128
+ pathKey,
1129
+ normalized.params,
1130
+ examples,
1131
+ options.baseUrlForExamples ?? 'https://api.open-mercato.local',
1132
+ metadata,
1133
+ requestBody
1134
+ )
1135
+
1136
+ doc.paths[pathKey][methodLower] = {
1137
+ operationId: methodDoc?.operationId
1138
+ ? normalized.omittedParam
1139
+ ? `${methodDoc.operationId}_without_${normalizeOperationIdSegment(normalized.omittedParam)}`
1140
+ : methodDoc.operationId
1141
+ : resolveOperationId(moduleEntry.id, pathKey, method),
1142
+ summary,
1143
+ description: descriptionParts.length ? descriptionParts.join('\n\n') : undefined,
1144
+ tags: methodDoc?.tags ?? (moduleTag ? [moduleTag] : undefined),
1145
+ deprecated: methodDoc?.deprecated,
1146
+ externalDocs: methodDoc?.externalDocs,
1147
+ parameters: [
1148
+ ...buildParameters(pathSchema, 'path', normalized.params),
1149
+ ...buildParameters(querySchema, 'query'),
1150
+ ...buildParameters(headerSchema, 'header'),
1151
+ ].filter(Boolean),
1152
+ requestBody: buildRequestBody(requestBody),
1153
+ responses: buildResponses(method, methodDoc?.responses, methodDoc?.errors, metadata),
1154
+ security: buildSecurity(metadata, methodDoc, options.defaultSecurity),
1155
+ 'x-codeSamples': methodDoc?.codeSamples ?? [
1156
+ {
1157
+ lang: 'curl',
1158
+ label: 'cURL',
1159
+ source: curlSample,
1160
+ },
1161
+ ],
1162
+ ...(includeAccessControlMetadata && Array.isArray(requireFeatures) && requireFeatures.length
1163
+ ? { 'x-require-features': requireFeatures }
1164
+ : {}),
1165
+ ...(includeAccessControlMetadata && Array.isArray(requireRoles) && requireRoles.length
1166
+ ? { 'x-require-roles': requireRoles }
1167
+ : {}),
1168
+ ...(requireAuth ? { 'x-require-auth': true } : {}),
1169
+ ...(methodDoc?.extensions ?? {}),
1170
+ }
1142
1171
  }
1143
1172
  }
1144
1173
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * End-to-end proof for #7502 against a real PostgreSQL: a `?ids=` filter that
3
+ * leaves no valid id (`{ id: { $in: [] } }`, the shape `mergeIdFilter` produces
4
+ * for an unparseable or disjoint id list) must answer an empty page, not a
5
+ * 42601 syntax error from Kysely compiling `in ()`.
6
+ *
7
+ * Gated on OM_COUNT_CAP_PG_URL (a throwaway database — the suite creates and
8
+ * drops its own table). Example:
9
+ * docker run --rm -d -p 54329:5432 -e POSTGRES_PASSWORD=t postgres:16
10
+ * OM_COUNT_CAP_PG_URL=postgres://postgres:t@localhost:54329/postgres yarn jest empty-ids-filter.pg
11
+ */
12
+ import { Kysely, PostgresDialect, sql } from 'kysely'
13
+ import { BasicQueryEngine } from '../engine'
14
+
15
+ const PG_URL = process.env.OM_COUNT_CAP_PG_URL
16
+ const maybe = PG_URL ? describe : describe.skip
17
+
18
+ const TENANT = '11111111-1111-4111-8111-111111111111'
19
+ const ORG = '22222222-2222-4222-8222-222222222222'
20
+ const ENTITY = 'capcheck:om_emptyids_order'
21
+ const TABLE = 'om_emptyids_orders'
22
+ const ID_A = '33333333-3333-4333-8333-000000000001'
23
+ const ID_B = '33333333-3333-4333-8333-000000000002'
24
+
25
+ maybe('empty $in/$nin filter against PostgreSQL', () => {
26
+ jest.setTimeout(30_000)
27
+ let db: Kysely<any>
28
+
29
+ beforeAll(async () => {
30
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
31
+ const { Pool } = require('pg')
32
+ db = new Kysely<any>({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: PG_URL }) }) })
33
+ await sql`drop table if exists om_emptyids_orders`.execute(db)
34
+ await sql`create table om_emptyids_orders (
35
+ id uuid primary key,
36
+ tenant_id uuid not null,
37
+ organization_id uuid not null,
38
+ deleted_at timestamptz,
39
+ status text not null
40
+ )`.execute(db)
41
+ await sql`insert into om_emptyids_orders (id, tenant_id, organization_id, deleted_at, status) values
42
+ (${ID_A}::uuid, ${TENANT}::uuid, ${ORG}::uuid, null, 'open'),
43
+ (${ID_B}::uuid, ${TENANT}::uuid, ${ORG}::uuid, null, 'closed')`.execute(db)
44
+ })
45
+
46
+ afterAll(async () => {
47
+ await sql`drop table if exists om_emptyids_orders`.execute(db)
48
+ await db.destroy()
49
+ })
50
+
51
+ const makeEngine = () => new BasicQueryEngine({} as any, () => db as any)
52
+ const baseOpts = {
53
+ tenantId: TENANT,
54
+ organizationId: ORG,
55
+ fields: ['id'],
56
+ page: { page: 1, pageSize: 10 },
57
+ }
58
+
59
+ test('?ids= with no valid id returns an empty page instead of a 500 (AND path)', async () => {
60
+ const result = await makeEngine().query(ENTITY, {
61
+ ...baseOpts,
62
+ filters: { id: { $in: [] } },
63
+ })
64
+ expect(result.items).toEqual([])
65
+ expect(result.total).toBe(0)
66
+ })
67
+
68
+ test('a disjoint ?id=&?ids= intersection inside an $or group still matches the other disjunct', async () => {
69
+ const result = await makeEngine().query(ENTITY, {
70
+ ...baseOpts,
71
+ filters: {
72
+ $or: [
73
+ { id: { $in: [] } },
74
+ { status: { $eq: 'closed' } },
75
+ ],
76
+ },
77
+ })
78
+ expect(result.items.map((row: any) => row.id)).toEqual([ID_B])
79
+ expect(result.total).toBe(1)
80
+ })
81
+
82
+ test('an empty $nin excludes nothing, matching every row', async () => {
83
+ const result = await makeEngine().query(ENTITY, {
84
+ ...baseOpts,
85
+ filters: { id: { $nin: [] } },
86
+ })
87
+ expect(result.total).toBe(2)
88
+ })
89
+ })
@@ -0,0 +1,79 @@
1
+ /**
2
+ * #7502: `mergeIdFilter` deliberately produces `{ $in: [] }` for a `?ids=` param
3
+ * that left no valid UUID (or a disjoint `?id=`+`?ids=` intersection) — the
4
+ * correct "match nothing" semantics. Both engine where-builders used to pass
5
+ * that empty array straight to Kysely's `.where(column, 'in', [])`, which
6
+ * compiles to invalid SQL (`in ()`) and makes Postgres answer 42601/500
7
+ * instead of an empty page. These tests pin the guard directly on the two
8
+ * private builder methods — no real SQL execution needed to prove the shape
9
+ * changed; `count-cap-plan.test.ts`-style Postgres-gated tests cover the
10
+ * end-to-end compiled-SQL behavior.
11
+ */
12
+ import { BasicQueryEngine } from '../engine'
13
+
14
+ describe('applyColumnOp empty $in/$nin guard (#7502)', () => {
15
+ const engine = new BasicQueryEngine({} as any, () => ({}) as any)
16
+
17
+ function callApplyColumnOp(op: string, value: unknown) {
18
+ const calls: any[] = []
19
+ const builder: any = {
20
+ where: (...args: any[]) => {
21
+ calls.push(args)
22
+ return builder
23
+ },
24
+ }
25
+ ;(engine as any).applyColumnOp(builder, 'id', op, value)
26
+ return calls
27
+ }
28
+
29
+ test('empty $in does not reach Kysely as an empty in-list', () => {
30
+ const calls = callApplyColumnOp('in', [])
31
+ expect(calls).toHaveLength(1)
32
+ // Pre-fix this called builder.where('id', 'in', []) -- three arguments, the
33
+ // shape Kysely compiles to invalid SQL. Post-fix it is a single predicate arg.
34
+ expect(calls[0]).not.toEqual(['id', 'in', []])
35
+ expect(calls[0]).toHaveLength(1)
36
+ })
37
+
38
+ test('non-empty $in keeps the original in-list predicate', () => {
39
+ const calls = callApplyColumnOp('in', ['a'])
40
+ expect(calls[0]).toEqual(['id', 'in', ['a']])
41
+ })
42
+
43
+ test('empty $nin does not reach Kysely as an empty not-in-list', () => {
44
+ const calls = callApplyColumnOp('nin', [])
45
+ expect(calls[0]).toHaveLength(1)
46
+ expect(calls[0]).not.toEqual(['id', 'not in', []])
47
+ })
48
+
49
+ test('non-empty $nin keeps the original not-in-list predicate', () => {
50
+ const calls = callApplyColumnOp('nin', ['a'])
51
+ expect(calls[0]).toEqual(['id', 'not in', ['a']])
52
+ })
53
+ })
54
+
55
+ describe('buildColumnOpExpression empty $in/$nin guard (#7502, OR-group path)', () => {
56
+ const engine = new BasicQueryEngine({} as any, () => ({}) as any)
57
+
58
+ function callBuildColumnOpExpression(op: string, value: unknown) {
59
+ const eb: any = (...args: any[]) => ({ kind: 'cmp', args })
60
+ eb.val = (v: any) => ({ kind: 'val', value: v })
61
+ return (engine as any).buildColumnOpExpression(eb, 'id', op, value)
62
+ }
63
+
64
+ test('empty $in compiles to a never-match literal, not an empty in-list comparison', () => {
65
+ expect(callBuildColumnOpExpression('in', [])).toEqual({ kind: 'val', value: false })
66
+ })
67
+
68
+ test('non-empty $in keeps the original in-list comparison', () => {
69
+ expect(callBuildColumnOpExpression('in', ['a'])).toEqual({ kind: 'cmp', args: ['id', 'in', ['a']] })
70
+ })
71
+
72
+ test('empty $nin compiles to an always-match literal, not an empty not-in-list comparison', () => {
73
+ expect(callBuildColumnOpExpression('nin', [])).toEqual({ kind: 'val', value: true })
74
+ })
75
+
76
+ test('non-empty $nin keeps the original not-in-list comparison', () => {
77
+ expect(callBuildColumnOpExpression('nin', ['a'])).toEqual({ kind: 'cmp', args: ['id', 'not in', ['a']] })
78
+ })
79
+ })
@@ -0,0 +1,219 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import {
3
+ DummyDriver,
4
+ Kysely,
5
+ PostgresAdapter,
6
+ PostgresIntrospector,
7
+ PostgresQueryCompiler,
8
+ sql,
9
+ type RawBuilder,
10
+ } from 'kysely'
11
+ import { BasicQueryEngine } from '../engine'
12
+ import { normalizeFilters } from '../join-utils'
13
+ import {
14
+ buildArrayColumnNoOverlapPredicate,
15
+ buildArrayColumnOverlapPredicate,
16
+ buildJsonbNoOverlapPredicate,
17
+ buildJsonbOverlapPredicate,
18
+ buildScalarOverlapPredicate,
19
+ jsonbDocKeyExpression,
20
+ normalizeOverlapValues,
21
+ } from '../overlap'
22
+ import type { FilterOp } from '../types'
23
+
24
+ function makeCompilingDb(): Kysely<any> {
25
+ return new Kysely<any>({
26
+ dialect: {
27
+ createAdapter: () => new PostgresAdapter(),
28
+ createDriver: () => new DummyDriver(),
29
+ createIntrospector: (db) => new PostgresIntrospector(db),
30
+ createQueryCompiler: () => new PostgresQueryCompiler(),
31
+ },
32
+ })
33
+ }
34
+
35
+ const db = makeCompilingDb()
36
+
37
+ function compileWhere(predicate: RawBuilder<boolean>): { sql: string; parameters: readonly unknown[] } {
38
+ const compiled = db.selectFrom('entity_indexes as ei').select('ei.id').where(predicate).compile()
39
+ return { sql: compiled.sql, parameters: compiled.parameters }
40
+ }
41
+
42
+ type EngineInternals = {
43
+ applyColumnOp: (builder: unknown, column: string, op: string, value: unknown) => { compile: () => { sql: string; parameters: readonly unknown[] } }
44
+ buildColumnOpExpression: (eb: unknown, column: string, op: string, value: unknown) => RawBuilder<boolean>
45
+ buildIndexDocOpExpression: (eb: unknown, opts: {
46
+ entity: string
47
+ field: string
48
+ op: FilterOp
49
+ value: unknown
50
+ recordIdColumn: string
51
+ tenantId?: string | null
52
+ withDeleted: boolean
53
+ }) => RawBuilder<boolean>
54
+ }
55
+
56
+ const engine = new BasicQueryEngine({} as EntityManager, () => db) as unknown as EngineInternals
57
+
58
+ describe('normalizeOverlapValues', () => {
59
+ it('flattens, stringifies, drops nullish and de-duplicates', () => {
60
+ expect(normalizeOverlapValues(['a', 'b', 'a', null, undefined, 3])).toEqual(['a', 'b', '3'])
61
+ expect(normalizeOverlapValues('a')).toEqual(['a'])
62
+ expect(normalizeOverlapValues(undefined)).toEqual([])
63
+ expect(normalizeOverlapValues(null)).toEqual([])
64
+ })
65
+ })
66
+
67
+ describe('overlap predicate builders', () => {
68
+ it('compiles a jsonb has-any-of with the values bound as one array parameter', () => {
69
+ const compiled = compileWhere(buildJsonbOverlapPredicate('ei.doc', 'scope_keys', ['cat:1', 'tag:2']))
70
+ expect(compiled.sql).toContain(`("ei"."doc" -> 'scope_keys') ?| $1::text[]`)
71
+ expect(compiled.parameters).toEqual([['cat:1', 'tag:2']])
72
+ })
73
+
74
+ it('never interpolates values into the SQL text', () => {
75
+ const hostile = "x'); drop table entity_indexes; --"
76
+ const compiled = compileWhere(buildJsonbOverlapPredicate('ei.doc', 'scope_keys', [hostile]))
77
+ expect(compiled.sql).not.toContain('drop table')
78
+ expect(compiled.parameters).toEqual([[hostile]])
79
+ })
80
+
81
+ it('binds a key that is not a plain identifier instead of inlining it', () => {
82
+ const key = "scope'keys"
83
+ const expression = db.selectFrom('entity_indexes as ei').select(jsonbDocKeyExpression('ei.doc', key).as('value')).compile()
84
+ expect(expression.sql).toContain('("ei"."doc" -> $1)')
85
+ expect(expression.parameters).toEqual([key])
86
+ })
87
+
88
+ it.each([
89
+ ['jsonb', () => buildJsonbOverlapPredicate('ei.doc', 'scope_keys', [])],
90
+ ['array column', () => buildArrayColumnOverlapPredicate('ei.tags', [])],
91
+ ['scalar', () => buildScalarOverlapPredicate(sql`ei.value`, [null, undefined])],
92
+ ])('compiles an empty %s value list to false (matches nothing)', (_label, build) => {
93
+ const compiled = compileWhere(build())
94
+ expect(compiled.sql).toMatch(/where false$/)
95
+ expect(compiled.parameters).toEqual([])
96
+ })
97
+
98
+ it('compiles array-column overlap to && with a bound array', () => {
99
+ const compiled = compileWhere(buildArrayColumnOverlapPredicate('ei.tags', ['a', 'b']))
100
+ expect(compiled.sql).toContain('"ei"."tags" && $1')
101
+ expect(compiled.parameters).toEqual([['a', 'b']])
102
+ })
103
+
104
+ it('compiles scalar overlap to an IN list of bound values', () => {
105
+ const compiled = compileWhere(buildScalarOverlapPredicate(sql`ei.value`, ['a', 'b']))
106
+ expect(compiled.sql).toContain('ei.value in ($1, $2)')
107
+ expect(compiled.parameters).toEqual(['a', 'b'])
108
+ })
109
+ })
110
+
111
+ describe('BasicQueryEngine overlap', () => {
112
+ it('normalizes $overlap from the object filter syntax', () => {
113
+ const filters = normalizeFilters({ scope_keys: { $overlap: ['cat:1'] } })
114
+ expect(filters).toEqual([expect.objectContaining({ field: 'scope_keys', op: 'overlap', value: ['cat:1'] })])
115
+ })
116
+
117
+ it('applies && on a base column', () => {
118
+ const builder = db.selectFrom('catalog_products as b').select('b.id')
119
+ const compiled = engine.applyColumnOp(builder, 'b.tags', 'overlap', ['x', 'y']).compile()
120
+ expect(compiled.sql).toContain('"b"."tags" && $1')
121
+ expect(compiled.parameters).toEqual([['x', 'y']])
122
+ })
123
+
124
+ it('compiles an index-document overlap inside the entity_indexes EXISTS', () => {
125
+ const compiled = db
126
+ .selectFrom('catalog_products as b')
127
+ .select('b.id')
128
+ .where((eb) => engine.buildIndexDocOpExpression(eb, {
129
+ entity: 'catalog:catalog_product',
130
+ field: 'scope_keys',
131
+ op: 'overlap',
132
+ value: ['cat:1'],
133
+ recordIdColumn: 'b.id',
134
+ tenantId: 'tenant-1',
135
+ withDeleted: false,
136
+ }))
137
+ .compile()
138
+ expect(compiled.sql).toMatch(/exists \(select 1 as "one" from "entity_indexes" as "ei_\d+"/)
139
+ expect(compiled.sql).toMatch(/\("ei_\d+"\."doc" -> 'scope_keys'\) \?\| \$\d+::text\[\]/)
140
+ expect(compiled.parameters).toContainEqual(['cat:1'])
141
+ })
142
+
143
+ it('leaves existing operators unchanged', () => {
144
+ const compiled = db
145
+ .selectFrom('catalog_products as b')
146
+ .select('b.id')
147
+ .where((eb) => eb.and([
148
+ engine.buildColumnOpExpression(eb, 'b.sku', 'in', ['a', 'b']),
149
+ engine.buildColumnOpExpression(eb, 'b.title', 'ilike', '%x%'),
150
+ engine.buildColumnOpExpression(eb, 'b.deleted_at', 'exists', false),
151
+ ]))
152
+ .compile()
153
+ expect(compiled.sql).toContain('"b"."sku" in ($1, $2)')
154
+ expect(compiled.sql).toContain('"b"."title" ilike $3')
155
+ expect(compiled.sql).toContain('"b"."deleted_at" is null')
156
+ expect(compiled.sql).not.toContain('&&')
157
+ expect(compiled.sql).not.toContain('?|')
158
+ })
159
+ })
160
+
161
+ describe('noverlap predicate builders', () => {
162
+ it('compiles a jsonb has-none-of that requires a present array', () => {
163
+ const compiled = compileWhere(buildJsonbNoOverlapPredicate('ei.doc', 'scope_keys', ['cat:1', 'tag:2']))
164
+ expect(compiled.sql).toContain(
165
+ `(jsonb_typeof(("ei"."doc" -> 'scope_keys')) = 'array' and not (("ei"."doc" -> 'scope_keys') ?| $1::text[]))`,
166
+ )
167
+ expect(compiled.parameters).toEqual([['cat:1', 'tag:2']])
168
+ })
169
+
170
+ it('keeps only the array check for an empty jsonb value list', () => {
171
+ const compiled = compileWhere(buildJsonbNoOverlapPredicate('ei.doc', 'scope_keys', []))
172
+ expect(compiled.sql).toMatch(/where jsonb_typeof\(\("ei"\."doc" -> 'scope_keys'\)\) = 'array'$/)
173
+ expect(compiled.parameters).toEqual([])
174
+ })
175
+
176
+ it('compiles array-column has-none-of to not && and an empty list to is not null', () => {
177
+ const compiled = compileWhere(buildArrayColumnNoOverlapPredicate('ei.tags', ['a']))
178
+ expect(compiled.sql).toContain('not ("ei"."tags" && $1)')
179
+ expect(compiled.parameters).toEqual([['a']])
180
+ expect(compileWhere(buildArrayColumnNoOverlapPredicate('ei.tags', [])).sql).toContain('"ei"."tags" is not null')
181
+ })
182
+ })
183
+
184
+ describe('BasicQueryEngine noverlap', () => {
185
+ it('normalizes $noverlap from the object filter syntax', () => {
186
+ const filters = normalizeFilters({ scope_keys: { $noverlap: ['cat:1'] } })
187
+ expect(filters).toEqual([expect.objectContaining({ field: 'scope_keys', op: 'noverlap', value: ['cat:1'] })])
188
+ })
189
+
190
+ it('compiles an index-document noverlap inside the entity_indexes EXISTS', () => {
191
+ const compiled = db
192
+ .selectFrom('catalog_products as b')
193
+ .select('b.id')
194
+ .where((eb) => engine.buildIndexDocOpExpression(eb, {
195
+ entity: 'catalog:catalog_product',
196
+ field: 'scope_keys',
197
+ op: 'noverlap',
198
+ value: ['cat:1'],
199
+ recordIdColumn: 'b.id',
200
+ tenantId: 'tenant-1',
201
+ withDeleted: false,
202
+ }))
203
+ .compile()
204
+ expect(compiled.sql).toMatch(/exists \(select 1 as "one" from "entity_indexes" as "ei_\d+"/)
205
+ expect(compiled.sql).toMatch(/not \(\("ei_\d+"\."doc" -> 'scope_keys'\) \?\| \$\d+::text\[\]\)/)
206
+ })
207
+
208
+ it('applies not && on a base column', () => {
209
+ const builder = db.selectFrom('catalog_products as b').select('b.id')
210
+ const compiled = engine.applyColumnOp(builder, 'b.tags', 'noverlap', ['x']).compile()
211
+ expect(compiled.sql).toContain('not ("b"."tags" && $1)')
212
+ const expression = db
213
+ .selectFrom('catalog_products as b')
214
+ .select('b.id')
215
+ .where((eb) => engine.buildColumnOpExpression(eb, 'b.tags', 'noverlap', ['x']))
216
+ .compile()
217
+ expect(expression.sql).toContain('not ("b"."tags" && $1)')
218
+ })
219
+ })